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 {