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
6 changed files with 258 additions and 97 deletions

View File

@@ -24,8 +24,8 @@ Add the dependencies to your module's `build.gradle.kts`:
```kotlin
dependencies {
// Replace 'v2.7.12' with the specific version you need
val meshtasticVersion = "v2.7.12"
// Replace 'v2.7.13' with the specific version you need
val meshtasticVersion = "v2.7.13"
// The core AIDL interface
implementation("com.github.meshtastic.Meshtastic-Android:core-api:$meshtasticVersion")
@@ -33,42 +33,70 @@ dependencies {
// Data models (DataPacket, MeshUser, NodeInfo, etc.)
implementation("com.github.meshtastic.Meshtastic-Android:core-model:$meshtasticVersion")
// Protobuf definitions (Portnums, Telemetry, etc.)
// Protobuf definitions (PortNum, Telemetry, etc.)
implementation("com.github.meshtastic.Meshtastic-Android:core-proto:$meshtasticVersion")
}
```
## Usage
1. **Bind to the Service:**
Use the `IMeshService` interface to bind to the Meshtastic service.
### 1. Bind to the Service
```kotlin
val intent = Intent("com.geeksville.mesh.Service")
intent.setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService")
bindService(intent, serviceConnection, BIND_AUTO_CREATE)
```
Use the `IMeshService` interface to bind to the Meshtastic service. It is recommended to query the package manager to find the correct service component, as the package name may vary between build flavors (e.g., Play Store vs. F-Droid).
2. **Interact with the API:**
Once bound, cast the `IBinder` to `IMeshService`:
```kotlin
val intent = Intent("com.geeksville.mesh.Service")
val resolveInfo = packageManager.queryIntentServices(intent, 0)
```kotlin
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val meshService = IMeshService.Stub.asInterface(service)
// Example: Send a text message
val packet = DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Hello Meshtastic!".toByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
// ... other fields
)
meshService.send(packet)
}
```
if (resolveInfo.isNotEmpty()) {
val serviceInfo = resolveInfo[0].serviceInfo
intent.setClassName(serviceInfo.packageName, serviceInfo.name)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
```
### 2. Interact with the API
Once bound, cast the `IBinder` to `IMeshService`:
```kotlin
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val meshService = IMeshService.Stub.asInterface(service)
// Example: Send a broadcast text message
val packet = DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Hello Meshtastic!".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
id = meshService.packetId,
wantAck = true
)
meshService.send(packet)
}
```
### 3. Register a BroadcastReceiver
To receive packets and status updates, register a `BroadcastReceiver`.
**Important:** On Android 13+ (API 33), you **must** use `RECEIVER_EXPORTED` since you are receiving broadcasts from a different application.
```kotlin
val intentFilter = IntentFilter().apply {
addAction("com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP")
addAction("com.geeksville.mesh.NODE_CHANGE")
addAction("com.geeksville.mesh.CONNECTION_CHANGED")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshtasticReceiver, intentFilter, Context.RECEIVER_EXPORTED)
} else {
registerReceiver(meshtasticReceiver, intentFilter)
}
```
## Modules
* **`core:api`**: Contains `IMeshService.aidl`.
* **`core:model`**: Contains Parcelable data classes like `DataPacket`, `MeshUser`, `NodeInfo`.
* **`core:proto`**: Contains the generated Protobuf code from `meshtastic/protobufs`.
* **`core:proto`**: Contains the generated Protobuf code (Wire).

View File

@@ -24,7 +24,7 @@ import okio.ByteString.Companion.toByteString
@Suppress("unused") // These are extension functions meant to be imported elsewhere
fun <T : Message<T, *>> ProtoAdapter<T>.decodeOrNull(bytes: ByteString?, logger: Logger? = null): T? {
if (bytes == null || bytes.size == 0) return null
if (bytes == null) return null
return runCatching { decode(bytes) }
.onFailure { exception -> logger?.e(exception) { "Failed to decode proto message" } }
.getOrNull()
@@ -40,7 +40,7 @@ fun <T : Message<T, *>> ProtoAdapter<T>.decodeOrNull(bytes: ByteString?, logger:
* @return The decoded message, or null if bytes is null or decoding fails
*/
fun <T : Message<T, *>> ProtoAdapter<T>.decodeOrNull(bytes: ByteArray?, logger: Logger? = null): T? {
if (bytes == null || bytes.isEmpty()) return null
if (bytes == null) return null
return decodeOrNull(bytes.toByteString(), logger)
}

View File

@@ -74,12 +74,14 @@ class WireExtensionsTest {
}
@Test
fun `decodeOrNull with empty ByteString returns null`() {
fun `decodeOrNull with empty ByteString returns empty message`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteString.EMPTY, testLogger)
// Assert
assertNull(result)
assertNotNull(result)
// An empty position should have null/default values
assertNull(result!!.latitude_i)
}
@Test
@@ -107,18 +109,20 @@ class WireExtensionsTest {
}
@Test
fun `decodeOrNull with empty ByteArray returns null`() {
fun `decodeOrNull with empty ByteArray returns empty message`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteArray(0), testLogger)
// Assert
assertNull(result)
assertNotNull(result)
assertNull(result!!.latitude_i)
}
@Test
fun `decodeOrNull with invalid data returns null`() {
// Arrange
val invalidBytes = byteArrayOfInts(0xFF, 0xFF, 0xFF, 0xFF).toByteString()
// A single byte 0xFF is an invalid field tag (field 0 is reserved and tags are varints)
val invalidBytes = ByteString.of(0xFF.toByte())
// Act - should not throw, should return null
val result = Position.ADAPTER.decodeOrNull(invalidBytes, testLogger)

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)