diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt index df45ab9a6..2a3361b3e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -76,7 +76,15 @@ constructor( @Volatile var lastNeighborInfo: NeighborInfo? = null private val rememberDataType = - setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.ALERT_APP.value, PortNum.WAYPOINT_APP.value) + setOf( + PortNum.TEXT_MESSAGE_APP.value, + PortNum.ALERT_APP.value, + PortNum.WAYPOINT_APP.value, + PortNum.ATAK_PLUGIN.value, + PortNum.ATAK_FORWARDER.value, + PortNum.DETECTION_SENSOR_APP.value, + PortNum.PRIVATE_APP.value, + ) fun start(scope: CoroutineScope) { this.scope = scope diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt new file mode 100644 index 000000000..e1c0cca2f --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt @@ -0,0 +1,122 @@ +/* + * 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 io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import okio.ByteString +import org.junit.Before +import org.junit.Test +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.service.ConnectionState +import org.meshtastic.proto.PortNum + +class MeshCommandSenderQueueTest { + + private val packetHandler = mockk(relaxed = true) + private val connectionStateHandler = mockk(relaxed = true) + private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) + + private lateinit var commandSender: MeshCommandSender + + @Before + fun setUp() { + every { connectionStateHandler.connectionState } returns connectionStateFlow.asStateFlow() + commandSender = MeshCommandSender(packetHandler, null, connectionStateHandler, null) + } + + @Test + fun `sendData queues TEXT_MESSAGE_APP when disconnected`() { + val packet = DataPacket(dataType = PortNum.TEXT_MESSAGE_APP.value, bytes = ByteString.EMPTY) + commandSender.sendData(packet) + + verify(exactly = 0) { packetHandler.sendToRadio(any()) } + + connectionStateFlow.value = ConnectionState.Connected + commandSender.processQueuedPackets() + + verify(exactly = 1) { packetHandler.sendToRadio(any()) } + } + + @Test + fun `sendData queues ATAK_PLUGIN when disconnected`() { + val packet = DataPacket(dataType = PortNum.ATAK_PLUGIN.value, bytes = ByteString.EMPTY) + commandSender.sendData(packet) + + verify(exactly = 0) { packetHandler.sendToRadio(any()) } + + connectionStateFlow.value = ConnectionState.Connected + commandSender.processQueuedPackets() + + verify(exactly = 1) { packetHandler.sendToRadio(any()) } + } + + @Test + fun `sendData queues ATAK_FORWARDER when disconnected`() { + val packet = DataPacket(dataType = PortNum.ATAK_FORWARDER.value, bytes = ByteString.EMPTY) + commandSender.sendData(packet) + + verify(exactly = 0) { packetHandler.sendToRadio(any()) } + + connectionStateFlow.value = ConnectionState.Connected + commandSender.processQueuedPackets() + + verify(exactly = 1) { packetHandler.sendToRadio(any()) } + } + + @Test + fun `sendData queues DETECTION_SENSOR_APP when disconnected`() { + val packet = DataPacket(dataType = PortNum.DETECTION_SENSOR_APP.value, bytes = ByteString.EMPTY) + commandSender.sendData(packet) + + verify(exactly = 0) { packetHandler.sendToRadio(any()) } + + connectionStateFlow.value = ConnectionState.Connected + commandSender.processQueuedPackets() + + verify(exactly = 1) { packetHandler.sendToRadio(any()) } + } + + @Test + fun `sendData queues PRIVATE_APP when disconnected`() { + val packet = DataPacket(dataType = PortNum.PRIVATE_APP.value, bytes = ByteString.EMPTY) + commandSender.sendData(packet) + + verify(exactly = 0) { packetHandler.sendToRadio(any()) } + + connectionStateFlow.value = ConnectionState.Connected + commandSender.processQueuedPackets() + + verify(exactly = 1) { packetHandler.sendToRadio(any()) } + } + + @Test + fun `sendData does NOT queue IP_TUNNEL_APP when disconnected`() { + val packet = DataPacket(dataType = PortNum.IP_TUNNEL_APP.value, bytes = ByteString.EMPTY) + commandSender.sendData(packet) + + verify(exactly = 0) { packetHandler.sendToRadio(any()) } + + connectionStateFlow.value = ConnectionState.Connected + commandSender.processQueuedPackets() + + verify(exactly = 0) { packetHandler.sendToRadio(any()) } + } +} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt index 02bd23d58..5c8376686 100644 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt @@ -92,6 +92,10 @@ class MainActivity : ComponentActivity() { addAction("com.geeksville.mesh.RECEIVED.POSITION_APP") addAction("com.geeksville.mesh.RECEIVED.TELEMETRY_APP") addAction("com.geeksville.mesh.RECEIVED.NODEINFO_APP") + addAction("com.geeksville.mesh.RECEIVED.ATAK_PLUGIN") + addAction("com.geeksville.mesh.RECEIVED.ATAK_FORWARDER") + addAction("com.geeksville.mesh.RECEIVED.DETECTION_SENSOR_APP") + addAction("com.geeksville.mesh.RECEIVED.PRIVATE_APP") } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt index 5ac969586..96024bf0f 100644 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt @@ -90,6 +90,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.meshtastic.core.model.NodeInfo +import org.meshtastic.proto.PortNum @Composable fun ListItem( @@ -229,6 +230,7 @@ private fun MainContent( ) { item { MyInfoSection(myId, myNodeInfo) } item { TitledCard(title = "Messaging") { MessagingSection(viewModel, lastMessage) } } + item { TitledCard(title = "Test Special PortNums") { SpecialAppSection(viewModel) } } item { SectionHeader( @@ -297,6 +299,28 @@ private fun MainContent( } } +@Composable +fun SpecialAppSection(viewModel: MeshServiceViewModel) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { viewModel.sendSpecialPacket(PortNum.ATAK_PLUGIN) }, modifier = Modifier.weight(1f)) { + Text("Send ATAK") + } + Button( + onClick = { viewModel.sendSpecialPacket(PortNum.DETECTION_SENSOR_APP) }, + modifier = Modifier.weight(1f), + ) { + Text("Send Sensor") + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { viewModel.sendSpecialPacket(PortNum.PRIVATE_APP) }, modifier = Modifier.weight(1f)) { + Text("Send Private") + } + } + } +} + @Composable private fun PacketLogContent(log: List) { Column(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp).verticalScroll(rememberScrollState())) { diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt index 3b8f77f20..a461c1525 100644 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt @@ -135,6 +135,31 @@ class MeshServiceViewModel : ViewModel() { } ?: Log.w(TAG, "MeshService is not bound, cannot send message") } + fun sendSpecialPacket(portNum: PortNum) { + meshService?.let { service -> + try { + val packet = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = "Special Payload for ${portNum.name}".encodeToByteArray().toByteString(), + dataType = portNum.value, + from = DataPacket.ID_LOCAL, + time = System.currentTimeMillis(), + id = service.packetId, + status = MessageStatus.UNKNOWN, + hopLimit = 3, + channel = 0, + wantAck = true, + ) + service.send(packet) + addToLog("Sent ${portNum.name} Packet (ID: ${packet.id})") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to send special packet", e) + addToLog("Failed to send ${portNum.name} packet: ${e.message}") + } + } + } + fun requestMyNodeInfo() { meshService?.let { try {