diff --git a/core/api/README.md b/core/api/README.md index a6f2a598d..880423760 100644 --- a/core/api/README.md +++ b/core/api/README.md @@ -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). diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt index 4389286d4..dd419c66e 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt @@ -24,7 +24,7 @@ import okio.ByteString.Companion.toByteString @Suppress("unused") // These are extension functions meant to be imported elsewhere fun > ProtoAdapter.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 > ProtoAdapter.decodeOrNull(bytes: ByteString?, logger: * @return The decoded message, or null if bytes is null or decoding fails */ fun > ProtoAdapter.decodeOrNull(bytes: ByteArray?, logger: Logger? = null): T? { - if (bytes == null || bytes.isEmpty()) return null + if (bytes == null) return null return decodeOrNull(bytes.toByteString(), logger) } diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt index 38a3c0b7a..b9ede858f 100644 --- a/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt @@ -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) 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 aeff88546..02bd23d58 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 @@ -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) +} 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 d12dc11d6..5ac969586 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 @@ -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) { + 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, - 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("") } 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 9f9672b34..3b8f77f20 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 @@ -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 = _connectionState.asStateFlow() + private val _packetLog = MutableStateFlow>(emptyList()) + val packetLog: StateFlow> = _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 Intent.getParcelableCompat(key: String, clazz: Class): T? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { getParcelableExtra(key, clazz)