feat(example): Add packet log and UI improvements (#4455)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-05 07:24:15 -06:00 committed by GitHub
parent c44d2f3268
commit f1520eb383
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 258 additions and 97 deletions

View file

@ -31,7 +31,14 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import org.meshtastic.core.service.IMeshService
private const val TAG: String = "MeshServiceExample"
@ -63,6 +70,7 @@ class MainActivity : ComponentActivity() {
private val meshtasticReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.d(TAG, "BroadcastReceiver onReceive: ${intent?.action}")
intent?.let { viewModel.handleIncomingIntent(it) }
}
}
@ -81,16 +89,19 @@ class MainActivity : ComponentActivity() {
addAction("com.geeksville.mesh.MESH_DISCONNECTED")
addAction("com.geeksville.mesh.MESSAGE_STATUS")
addAction("com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP")
addAction("com.geeksville.mesh.RECEIVED.POSITION_APP")
addAction("com.geeksville.mesh.RECEIVED.TELEMETRY_APP")
addAction("com.geeksville.mesh.RECEIVED.NODEINFO_APP")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_NOT_EXPORTED)
registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(meshtasticReceiver, intentFilter)
}
setContent { MaterialTheme { MainScreen(viewModel) } }
setContent { ExampleTheme { MainScreen(viewModel) } }
}
override fun onDestroy() {
@ -104,8 +115,6 @@ class MainActivity : ComponentActivity() {
Log.i(TAG, "Attempting to bind to Mesh Service...")
val intent = Intent("com.geeksville.mesh.Service")
// Query the package manager to find an app that handles this service action.
// This is more resilient than hardcoding a package name, which might change with flavors.
val resolveInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(0))
@ -144,3 +153,18 @@ class MainActivity : ComponentActivity() {
}
}
}
@Composable
fun ExampleTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colorScheme =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(colorScheme = colorScheme, content = content)
}

View file

@ -19,6 +19,7 @@
package com.meshtastic.android.meshserviceexample
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -28,16 +29,22 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.BatteryUnknown
import androidx.compose.material.icons.automirrored.rounded.Message
import androidx.compose.material.icons.automirrored.rounded.Send
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.GpsFixed
import androidx.compose.material.icons.rounded.GpsOff
import androidx.compose.material.icons.rounded.Hub
@ -77,6 +84,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -113,6 +121,27 @@ fun TitledCard(title: String, content: @Composable () -> Unit) {
}
}
@Composable
fun SectionHeader(title: String, expanded: Boolean, onExpandClick: () -> Unit, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().clickable { onExpandClick() },
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: MeshServiceViewModel) {
@ -177,6 +206,7 @@ private fun TopBarTitle(isConnected: Boolean, connectionState: String) {
}
@Composable
@Suppress("LongMethod")
private fun MainContent(
viewModel: MeshServiceViewModel,
innerPadding: PaddingValues,
@ -186,6 +216,11 @@ private fun MainContent(
val myId by viewModel.myId.collectAsState()
val nodes by viewModel.nodes.collectAsState()
val lastMessage by viewModel.message.collectAsState()
val packetLog by viewModel.packetLog.collectAsState()
var nodesExpanded by remember { mutableStateOf(false) }
var logExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
@ -194,20 +229,98 @@ private fun MainContent(
) {
item { MyInfoSection(myId, myNodeInfo) }
item { TitledCard(title = "Messaging") { MessagingSection(viewModel, lastMessage) } }
if (nodes.isNotEmpty()) {
item {
TitledCard(title = "Mesh Nodes (${nodes.size})") {
NodeListContent(nodes, viewModel, snackbarHostState)
item {
SectionHeader(
title = "Mesh Nodes (${nodes.size})",
expanded = nodesExpanded,
onExpandClick = { nodesExpanded = !nodesExpanded },
)
}
if (nodesExpanded) {
if (nodes.isEmpty()) {
item { EmptyNodeState() }
} else {
items(nodes) { node ->
Card(modifier = Modifier.fillMaxWidth()) {
val nodeLabel = node.user?.longName ?: node.user?.id ?: "Unknown Node"
NodeItem(node) { action ->
scope.launch {
when (action) {
"traceroute" -> {
viewModel.requestTraceroute(node.num)
snackbarHostState.showSnackbar("Traceroute requested for $nodeLabel")
}
"telemetry" -> {
viewModel.requestTelemetry(node.num)
snackbarHostState.showSnackbar("Telemetry requested for $nodeLabel")
}
"neighbors" -> {
viewModel.requestNeighborInfo(node.num)
snackbarHostState.showSnackbar("Neighbor info requested for $nodeLabel")
}
"position" -> {
viewModel.requestPosition(node.num)
snackbarHostState.showSnackbar("Position requested for $nodeLabel")
}
"userinfo" -> {
viewModel.requestUserInfo(node.num)
snackbarHostState.showSnackbar("User info requested for $nodeLabel")
}
"connstatus" -> {
viewModel.requestDeviceConnectionStatus(node.num)
snackbarHostState.showSnackbar("Connection status requested for $nodeLabel")
}
}
}
}
}
}
}
} else {
item { EmptyNodeState() }
}
item {
SectionHeader(title = "Packet Log", expanded = logExpanded, onExpandClick = { logExpanded = !logExpanded })
}
if (logExpanded) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.padding(16.dp)) { PacketLogContent(packetLog) }
}
}
}
item { ActionButtons(viewModel, snackbarHostState) }
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
@Composable
private fun PacketLogContent(log: List<String>) {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp).verticalScroll(rememberScrollState())) {
if (log.isEmpty()) {
Text(
text = "No packets yet.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
log.forEach { entry ->
Text(
text = entry,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 2.dp),
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
}
}
}
}
@Composable
private fun MyInfoSection(myId: String?, myNodeInfo: org.meshtastic.core.model.MyNodeInfo?) {
TitledCard(title = "My Node Information") {
@ -237,54 +350,6 @@ private fun EmptyNodeState() {
)
}
@Composable
private fun NodeListContent(
nodes: List<NodeInfo>,
viewModel: MeshServiceViewModel,
snackbarHostState: SnackbarHostState,
) {
val scope = rememberCoroutineScope()
nodes.forEachIndexed { index, node ->
val nodeLabel = node.user?.longName ?: node.user?.id ?: "Unknown Node"
NodeItem(node) { action ->
scope.launch {
when (action) {
"traceroute" -> {
viewModel.requestTraceroute(node.num)
snackbarHostState.showSnackbar("Traceroute requested for $nodeLabel")
}
"telemetry" -> {
viewModel.requestTelemetry(node.num)
snackbarHostState.showSnackbar("Telemetry requested for $nodeLabel")
}
"neighbors" -> {
viewModel.requestNeighborInfo(node.num)
snackbarHostState.showSnackbar("Neighbor info requested for $nodeLabel")
}
"position" -> {
viewModel.requestPosition(node.num)
snackbarHostState.showSnackbar("Position requested for $nodeLabel")
}
"userinfo" -> {
viewModel.requestUserInfo(node.num)
snackbarHostState.showSnackbar("User info requested for $nodeLabel")
}
"connstatus" -> {
viewModel.requestDeviceConnectionStatus(node.num)
snackbarHostState.showSnackbar("Connection status requested for $nodeLabel")
}
}
}
}
if (index < nodes.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}
@Composable
fun MessagingSection(viewModel: MeshServiceViewModel, lastMessage: String) {
var textToSend by remember { mutableStateOf("") }

View file

@ -33,6 +33,9 @@ import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.service.IMeshService
import org.meshtastic.proto.PortNum
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.random.Random
private const val TAG = "MeshServiceViewModel"
@ -61,15 +64,20 @@ class MeshServiceViewModel : ViewModel() {
private val _connectionState = MutableStateFlow("UNKNOWN")
val connectionState: StateFlow<String> = _connectionState.asStateFlow()
private val _packetLog = MutableStateFlow<List<String>>(emptyList())
val packetLog: StateFlow<List<String>> = _packetLog.asStateFlow()
fun onServiceConnected(service: IMeshService?) {
meshService = service
_serviceConnectionStatus.value = true
updateAllData()
addToLog("Service Connected")
}
fun onServiceDisconnected() {
meshService = null
_serviceConnectionStatus.value = false
addToLog("Service Disconnected")
}
private fun updateAllData() {
@ -92,7 +100,9 @@ class MeshServiceViewModel : ViewModel() {
fun updateConnectionState() {
meshService?.let {
try {
_connectionState.value = it.connectionState() ?: "UNKNOWN"
val state = it.connectionState() ?: "UNKNOWN"
_connectionState.value = state
addToLog("Connection State: $state")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to get connection state", e)
}
@ -109,7 +119,7 @@ class MeshServiceViewModel : ViewModel() {
dataType = PortNum.TEXT_MESSAGE_APP.value,
from = DataPacket.ID_LOCAL,
time = System.currentTimeMillis(),
id = service.packetId, // Correctly sync with radio's ID
id = service.packetId,
status = MessageStatus.UNKNOWN,
hopLimit = 3,
channel = 0,
@ -117,8 +127,10 @@ class MeshServiceViewModel : ViewModel() {
)
service.send(packet)
Log.d(TAG, "Message sent successfully, assigned ID: ${packet.id}")
addToLog("Sent: $text (ID: ${packet.id})")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to send message", e)
addToLog("Failed to send message: ${e.message}")
}
} ?: Log.w(TAG, "MeshService is not bound, cannot send message")
}
@ -146,6 +158,7 @@ class MeshServiceViewModel : ViewModel() {
fun startProvideLocation() {
try {
meshService?.startProvideLocation()
addToLog("Started GPS sharing")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to start providing location", e)
}
@ -154,6 +167,7 @@ class MeshServiceViewModel : ViewModel() {
fun stopProvideLocation() {
try {
meshService?.stopProvideLocation()
addToLog("Stopped GPS sharing")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to stop providing location", e)
}
@ -164,6 +178,7 @@ class MeshServiceViewModel : ViewModel() {
try {
it.requestTraceroute(Random.nextInt(), nodeNum)
Log.i(TAG, "Traceroute requested for node $nodeNum")
addToLog("Requested Traceroute for $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request traceroute", e)
}
@ -173,9 +188,9 @@ class MeshServiceViewModel : ViewModel() {
fun requestTelemetry(nodeNum: Int) {
meshService?.let {
try {
// DEVICE_METRICS_FIELD_NUMBER = 1
it.requestTelemetry(Random.nextInt(), nodeNum, 1)
Log.i(TAG, "Telemetry requested for node $nodeNum")
addToLog("Requested Telemetry for $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request telemetry", e)
}
@ -187,6 +202,7 @@ class MeshServiceViewModel : ViewModel() {
try {
it.requestNeighborInfo(Random.nextInt(), nodeNum)
Log.i(TAG, "Neighbor info requested for node $nodeNum")
addToLog("Requested Neighbors for $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request neighbor info", e)
}
@ -198,6 +214,7 @@ class MeshServiceViewModel : ViewModel() {
try {
it.requestPosition(nodeNum, Position(0.0, 0.0, 0))
Log.i(TAG, "Position requested for node $nodeNum")
addToLog("Requested Position for $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request position", e)
}
@ -209,6 +226,7 @@ class MeshServiceViewModel : ViewModel() {
try {
it.requestUserInfo(nodeNum)
Log.i(TAG, "User info requested for node $nodeNum")
addToLog("Requested User Info for $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request user info", e)
}
@ -220,6 +238,7 @@ class MeshServiceViewModel : ViewModel() {
try {
it.getDeviceConnectionStatus(Random.nextInt(), nodeNum)
Log.i(TAG, "Device connection status requested for node $nodeNum")
addToLog("Requested Connection Status for $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request device connection status", e)
}
@ -231,6 +250,7 @@ class MeshServiceViewModel : ViewModel() {
try {
it.requestReboot(Random.nextInt(), 0)
Log.w(TAG, "Local reboot requested!")
addToLog("Requested Local Reboot")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request reboot", e)
}
@ -247,6 +267,7 @@ class MeshServiceViewModel : ViewModel() {
"com.geeksville.mesh.MESH_CONNECTED",
"com.geeksville.mesh.MESH_DISCONNECTED",
-> updateConnectionState()
"com.geeksville.mesh.MESSAGE_STATUS" -> handleMessageStatus(intent)
else ->
if (action.startsWith("com.geeksville.mesh.RECEIVED.")) {
@ -271,18 +292,37 @@ class MeshServiceViewModel : ViewModel() {
val id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0)
val status = intent.getParcelableCompat("com.geeksville.mesh.Status", MessageStatus::class.java)
Log.d(TAG, "Message Status for ID $id: $status")
addToLog("Msg Status ID $id: $status")
}
private fun handleReceivedPacket(action: String, intent: Intent) {
val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java) ?: return
val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java)
if (packet == null) {
Log.e(TAG, "Received packet extra was NULL for action: $action")
addToLog("Error: Packet payload was null for $action")
return
}
Log.d(TAG, "Packet received: $packet")
if (packet.dataType == PortNum.TEXT_MESSAGE_APP.value) {
val receivedText = packet.bytes?.utf8() ?: ""
_message.value = "From ${packet.from}: $receivedText"
addToLog("Received Text from ${packet.from}: $receivedText")
} else {
_message.value = "Received port ${action.substringAfterLast(".")} packet"
val type = action.substringAfterLast(".")
addToLog("Received $type from ${packet.from}. Check Logcat for details.")
}
}
private fun addToLog(entry: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
val logEntry = "[$timestamp] $entry"
Log.d(TAG, "Log: $logEntry")
@Suppress("MagicNumber")
_packetLog.value = (listOf(logEntry) + _packetLog.value).take(50)
}
private fun <T : Parcelable> Intent.getParcelableCompat(key: String, clazz: Class<T>): T? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(key, clazz)