mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
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:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("") }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user