feat(service): Overhaul MeshServiceExample (#4263)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-01-19 18:58:26 -06:00
committed by GitHub
parent 8e4541c147
commit 4e2c429180
11 changed files with 1156 additions and 236 deletions

View File

@@ -38,6 +38,8 @@ plugins { alias(libs.plugins.meshtastic.android.library) }
configure<LibraryExtension> {
buildFeatures { aidl = true }
namespace = "org.meshtastic.core.service"
testOptions { unitTests.isReturnDefaultValues = true }
}
dependencies {

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service.testing
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.service.IMeshService
/**
* A fake implementation of [IMeshService] for testing purposes. This also serves as a contract verification: if the
* AIDL changes, this class will fail to compile.
*
* Developers can use this to mock the MeshService in their unit tests.
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
open class FakeIMeshService : IMeshService.Stub() {
override fun subscribeReceiver(packageName: String?, receiverName: String?) {}
override fun setOwner(user: MeshUser?) {}
override fun setRemoteOwner(requestId: Int, payload: ByteArray?) {}
override fun getRemoteOwner(requestId: Int, destNum: Int) {}
override fun getMyId(): String = "fake_id"
override fun getPacketId(): Int = 1234
override fun send(packet: DataPacket?) {}
override fun getNodes(): List<NodeInfo> = emptyList()
override fun getConfig(): ByteArray = byteArrayOf()
override fun setConfig(payload: ByteArray?) {}
override fun setRemoteConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getRemoteConfig(requestId: Int, destNum: Int, configTypeValue: Int) {}
override fun setModuleConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getModuleConfig(requestId: Int, destNum: Int, moduleConfigTypeValue: Int) {}
override fun setRingtone(destNum: Int, ringtone: String?) {}
override fun getRingtone(requestId: Int, destNum: Int) {}
override fun setCannedMessages(destNum: Int, messages: String?) {}
override fun getCannedMessages(requestId: Int, destNum: Int) {}
override fun setChannel(payload: ByteArray?) {}
override fun setRemoteChannel(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getRemoteChannel(requestId: Int, destNum: Int, channelIndex: Int) {}
override fun beginEditSettings() {}
override fun commitEditSettings() {}
override fun removeByNodenum(requestID: Int, nodeNum: Int) {}
override fun requestPosition(destNum: Int, position: Position?) {}
override fun setFixedPosition(destNum: Int, position: Position?) {}
override fun requestTraceroute(requestId: Int, destNum: Int) {}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override fun requestShutdown(requestId: Int, destNum: Int) {}
override fun requestReboot(requestId: Int, destNum: Int) {}
override fun requestFactoryReset(requestId: Int, destNum: Int) {}
override fun rebootToDfu() {}
override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {}
override fun getChannelSet(): ByteArray = byteArrayOf()
override fun connectionState(): String = "CONNECTED"
override fun setDeviceAddress(deviceAddr: String?): Boolean = true
override fun getMyNodeInfo(): MyNodeInfo? = null
override fun startFirmwareUpdate() {}
override fun getUpdateStatus(): Int = 0
override fun startProvideLocation() {}
override fun stopProvideLocation() {}
override fun requestUserInfo(destNum: Int) {}
override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) {}
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
/**
* A fake implementation of [IMeshService] for testing purposes. This also serves as a contract verification: if the
* AIDL changes, this class will fail to compile.
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
open class FakeIMeshService : IMeshService.Stub() {
override fun subscribeReceiver(packageName: String?, receiverName: String?) {}
override fun setOwner(user: MeshUser?) {}
override fun setRemoteOwner(requestId: Int, payload: ByteArray?) {}
override fun getRemoteOwner(requestId: Int, destNum: Int) {}
override fun getMyId(): String = "fake_id"
override fun getPacketId(): Int = 1234
override fun send(packet: DataPacket?) {}
override fun getNodes(): List<NodeInfo> = emptyList()
override fun getConfig(): ByteArray = byteArrayOf()
override fun setConfig(payload: ByteArray?) {}
override fun setRemoteConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getRemoteConfig(requestId: Int, destNum: Int, configTypeValue: Int) {}
override fun setModuleConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getModuleConfig(requestId: Int, destNum: Int, moduleConfigTypeValue: Int) {}
override fun setRingtone(destNum: Int, ringtone: String?) {}
override fun getRingtone(requestId: Int, destNum: Int) {}
override fun setCannedMessages(destNum: Int, messages: String?) {}
override fun getCannedMessages(requestId: Int, destNum: Int) {}
override fun setChannel(payload: ByteArray?) {}
override fun setRemoteChannel(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getRemoteChannel(requestId: Int, destNum: Int, channelIndex: Int) {}
override fun beginEditSettings() {}
override fun commitEditSettings() {}
override fun removeByNodenum(requestID: Int, nodeNum: Int) {}
override fun requestPosition(destNum: Int, position: Position?) {}
override fun setFixedPosition(destNum: Int, position: Position?) {}
override fun requestTraceroute(requestId: Int, destNum: Int) {}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override fun requestShutdown(requestId: Int, destNum: Int) {}
override fun requestReboot(requestId: Int, destNum: Int) {}
override fun requestFactoryReset(requestId: Int, destNum: Int) {}
override fun rebootToDfu() {}
override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {}
override fun getChannelSet(): ByteArray = byteArrayOf()
override fun connectionState(): String = "CONNECTED"
override fun setDeviceAddress(deviceAddr: String?): Boolean = true
override fun getMyNodeInfo(): MyNodeInfo? = null
override fun startFirmwareUpdate() {}
override fun getUpdateStatus(): Int = 0
override fun startProvideLocation() {}
override fun stopProvideLocation() {}
override fun requestUserInfo(destNum: Int) {}
override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) {}
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.meshtastic.core.service.testing.FakeIMeshService
/** Test to verify that the AIDL contract is correctly implemented by our test harness. */
class IMeshServiceContractTest {
@Test
fun `verify fake implementation matches aidl contract`() {
val service: IMeshService = FakeIMeshService()
// Basic verification that we can call methods and get expected results
assertEquals("fake_id", service.myId)
assertEquals(1234, service.packetId)
assertEquals("CONNECTED", service.connectionState())
assertNotNull(service.nodes)
}
}

View File

@@ -32,18 +32,23 @@ configure<ApplicationExtension> {
// Force this app to use the Google variant of any modules it's using that apply AndroidLibraryConventionPlugin
missingDimensionStrategy(FlavorDimension.marketplace.name, MeshtasticFlavor.google.name)
}
testOptions { unitTests.isReturnDefaultValues = true }
}
dependencies {
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.ui)
implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.material)
// OSM
implementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,10 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.meshtastic.android.meshserviceexample
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
@@ -28,201 +26,98 @@ import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeInfo
import androidx.activity.viewModels
import org.meshtastic.core.service.IMeshService
import org.meshtastic.proto.Portnums
import org.meshtastic.core.ui.theme.AppTheme
private const val TAG: String = "MeshServiceExample"
class MainActivity : AppCompatActivity() {
/** MainActivity for the MeshServiceExample application. */
class MainActivity : ComponentActivity() {
private var meshService: IMeshService? = null
private var serviceConnection: ServiceConnection? = null
private var isMeshServiceBound = false
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
@Suppress("TooGenericExceptionCaught", "LongMethod")
private val viewModel: MeshServiceViewModel by viewModels()
private val serviceConnection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
meshService = IMeshService.Stub.asInterface(service)
Log.i(TAG, "Connected to MeshService")
isMeshServiceBound = true
viewModel.onServiceConnected(meshService)
}
override fun onServiceDisconnected(name: ComponentName?) {
meshService = null
isMeshServiceBound = false
viewModel.onServiceDisconnected()
}
}
private val meshtasticReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let { viewModel.handleIncomingIntent(it) }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
this.enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
enableEdgeToEdge()
val mainTextView = findViewById<TextView>(R.id.mainTextView)
val statusImageView = findViewById<ImageView>(R.id.statusImageView)
bindMeshService()
findViewById<Button>(R.id.sendBtn).setOnClickListener { _ ->
meshService?.let {
try {
it.send(
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Hello from MeshServiceExample".toByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
from = DataPacket.ID_LOCAL,
time = System.currentTimeMillis(),
id = 0,
status = MessageStatus.UNKNOWN,
hopLimit = 3,
channel = 0,
wantAck = true,
),
)
Log.d(TAG, "Message sent successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to send message", e)
}
} ?: Log.w(TAG, "MeshService is not bound, cannot send message")
}
// Now you can call methods on meshService
serviceConnection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
meshService = IMeshService.Stub.asInterface(service)
Log.i(TAG, "Connected to MeshService")
isMeshServiceBound = true
statusImageView.setImageResource(android.R.color.holo_green_light)
}
override fun onServiceDisconnected(name: ComponentName?) {
meshService = null
isMeshServiceBound = false
}
}
// Handle the received broadcast
// handle node changed
// handle position app data
val meshtasticReceiver: BroadcastReceiver =
object : BroadcastReceiver() {
@SuppressLint("SetTextI18n")
@Suppress("ReturnCount")
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null) {
Log.w(TAG, "Received null intent")
return
}
val action = intent.action
if (intent.action == null) {
Log.w(TAG, "Received null action")
return
}
Log.d(TAG, "Received broadcast: $action")
when (action) {
"com.geeksville.mesh.NODE_CHANGE" ->
try {
val ni = intent.getParcelableExtra("com.geeksville.mesh.NodeInfo", NodeInfo::class.java)
Log.d(TAG, "NodeInfo: $ni")
mainTextView.text = "NodeInfo: $ni"
} catch (e: Exception) {
Log.e(TAG, "onReceive: ${e.message}")
return
}
"com.geeksville.mesh.MESSAGE_STATUS" -> {
val id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0)
val status =
intent.getParcelableExtra("com.geeksville.mesh.Status", MessageStatus::class.java)
Log.d(TAG, "Message Status ID: $id Status: $status")
}
"com.geeksville.mesh.MESH_CONNECTED" -> {
val extraConnected = intent.getStringExtra("com.geeksville.mesh.Connected")
val connected = extraConnected.equals("connected", ignoreCase = true)
Log.d(TAG, "Received ACTION_MESH_CONNECTED: $extraConnected")
if (connected) {
statusImageView.setImageResource(android.R.color.holo_green_light)
}
}
"com.geeksville.mesh.MESH_DISCONNECTED" -> {
val extraConnected = intent.getStringExtra("com.geeksville.mesh.Disconnected")
val disconnected = extraConnected.equals("disconnected", ignoreCase = true)
Log.d(TAG, "Received ACTION_MESH_DISCONNECTED: $extraConnected")
if (disconnected) {
statusImageView.setImageResource(android.R.color.holo_red_light)
}
}
"com.geeksville.mesh.RECEIVED.POSITION_APP" -> {
try {
val ni = intent.getParcelableExtra("com.geeksville.mesh.NodeInfo", NodeInfo::class.java)
Log.d(TAG, "Position App NodeInfo: $ni")
mainTextView.text = "Position App NodeInfo: $ni"
} catch (e: Exception) {
Log.e(TAG, "onReceive: $e")
return
}
}
else -> Log.w(TAG, "Unknown action: $action")
}
}
}
val filter =
val intentFilter =
IntentFilter().apply {
addAction("com.geeksville.mesh.NODE_CHANGE")
addAction("com.geeksville.mesh.RECEIVED.NODEINFO_APP")
addAction("com.geeksville.mesh.RECEIVED.POSITION_APP")
addAction("com.geeksville.mesh.CONNECTION_CHANGED")
addAction("com.geeksville.mesh.MESH_CONNECTED")
addAction("com.geeksville.mesh.MESH_DISCONNECTED")
addAction("com.geeksville.mesh.MESSAGE_STATUS")
addAction("com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP")
}
registerReceiver(meshtasticReceiver, filter, RECEIVER_NOT_EXPORTED)
Log.d(TAG, "Registered meshtasticPacketReceiver")
while (!bindMeshService()) {
try {
@Suppress("MagicNumber")
Thread.sleep(1_000)
} catch (e: InterruptedException) {
Log.e(TAG, "Binding interrupted", e)
break
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(meshtasticReceiver, intentFilter)
}
setContent { AppTheme { MainScreen(viewModel) } }
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(meshtasticReceiver)
unbindMeshService()
}
private fun bindMeshService(): Boolean {
private fun bindMeshService() {
try {
Log.i(TAG, "Attempting to bind to Mesh Service...")
val intent = Intent("com.geeksville.mesh.Service")
intent.setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService")
return bindService(intent, serviceConnection!!, BIND_AUTO_CREATE)
} catch (e: java.lang.Exception) {
Log.e(TAG, "Failed to bind", e)
val success = bindService(intent, serviceConnection, BIND_AUTO_CREATE)
if (!success) {
Log.e(TAG, "bindService returned false")
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException while binding", e)
}
return false
}
private fun unbindMeshService() {
if (isMeshServiceBound) {
try {
unbindService(serviceConnection!!)
unbindService(serviceConnection)
} catch (e: IllegalArgumentException) {
Log.w(TAG, "MeshService not registered or already unbound: " + e.message)
Log.w(TAG, "MeshService not registered or already unbound: ${e.message}")
}
isMeshServiceBound = false
meshService = null

View File

@@ -0,0 +1,466 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
package com.meshtastic.android.meshserviceexample
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
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.GpsFixed
import androidx.compose.material.icons.rounded.GpsOff
import androidx.compose.material.icons.rounded.Hub
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.MyLocation
import androidx.compose.material.icons.rounded.PersonSearch
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Router
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: MeshServiceViewModel) {
val isConnected by viewModel.serviceConnectionStatus.collectAsState()
val connectionState by viewModel.connectionState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { TopBarTitle(isConnected, connectionState) },
actions = {
IconButton(
onClick = {
viewModel.requestNodes()
scope.launch { snackbarHostState.showSnackbar("Refreshing nodes...") }
},
) {
Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Nodes")
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
),
)
},
) { innerPadding ->
MainContent(viewModel, innerPadding, snackbarHostState)
}
}
@Composable
private fun TopBarTitle(isConnected: Boolean, connectionState: String) {
Column {
Text(
text = "Mesh Service Example",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
)
Row(verticalAlignment = Alignment.CenterVertically) {
val statusColor =
if (isConnected) {
MaterialTheme.colorScheme.StatusGreen
} else {
MaterialTheme.colorScheme.error
}
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(statusColor))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isConnected) "Connected ($connectionState)" else "Disconnected",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun MainContent(
viewModel: MeshServiceViewModel,
innerPadding: PaddingValues,
snackbarHostState: SnackbarHostState,
) {
val myNodeInfo by viewModel.myNodeInfo.collectAsState()
val myId by viewModel.myId.collectAsState()
val nodes by viewModel.nodes.collectAsState()
val lastMessage by viewModel.message.collectAsState()
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
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)
}
}
} else {
item { EmptyNodeState() }
}
item { ActionButtons(viewModel, snackbarHostState) }
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
@Composable
private fun MyInfoSection(myId: String?, myNodeInfo: org.meshtastic.core.model.MyNodeInfo?) {
TitledCard(title = "My Node Information") {
ListItem(
text = "Long ID",
supportingText = myId ?: "N/A",
leadingIcon = Icons.Rounded.AccountCircle,
trailingIcon = null,
)
ListItem(
text = "Firmware",
supportingText = myNodeInfo?.firmwareString ?: "N/A",
leadingIcon = Icons.Rounded.Info,
trailingIcon = null,
)
}
}
@Composable
private fun EmptyNodeState() {
Text(
text = "No mesh nodes discovered yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
textAlign = TextAlign.Center,
)
}
@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("") }
Column(modifier = Modifier.padding(16.dp)) {
if (lastMessage.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
ListItem(
text = "Last Received",
supportingText = lastMessage,
leadingIcon = Icons.AutoMirrored.Rounded.Message,
trailingIcon = null,
)
}
Spacer(modifier = Modifier.height(12.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = textToSend,
onValueChange = { textToSend = it },
modifier = Modifier.weight(1f),
label = { Text("Send broadcast message") },
shape = MaterialTheme.shapes.large,
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (textToSend.isNotBlank()) {
viewModel.sendMessage(textToSend)
textToSend = ""
}
},
modifier = Modifier.size(56.dp),
shape = MaterialTheme.shapes.large,
contentPadding = PaddingValues(0.dp),
) {
Icon(imageVector = Icons.AutoMirrored.Rounded.Send, contentDescription = "Send")
}
}
}
}
@Composable
fun NodeItem(node: NodeInfo, onAction: (String) -> Unit) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
NodeItemHeader(node)
Spacer(modifier = Modifier.height(8.dp))
NodeItemActions(node.isOnline, onAction)
}
}
@Composable
private fun NodeItemHeader(node: NodeInfo) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(contentAlignment = Alignment.BottomEnd) {
Icon(
imageVector = Icons.Rounded.AccountCircle,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.outline,
)
if (node.isOnline) {
Box(
modifier =
Modifier.size(14.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(2.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.StatusGreen),
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = node.user?.longName ?: "Unknown Node",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "ID: ${node.user?.id ?: "N/A"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun NodeItemActions(isOnline: Boolean, onAction: (String) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { onAction("traceroute") }, modifier = Modifier.size(40.dp)) {
Icon(Icons.Rounded.Route, "Traceroute", Modifier.size(20.dp), MaterialTheme.colorScheme.primary)
}
IconButton(onClick = { onAction("telemetry") }, modifier = Modifier.size(40.dp)) {
Icon(
Icons.AutoMirrored.Rounded.BatteryUnknown,
"Telemetry",
Modifier.size(20.dp),
MaterialTheme.colorScheme.secondary,
)
}
IconButton(onClick = { onAction("position") }, modifier = Modifier.size(40.dp)) {
Icon(Icons.Rounded.MyLocation, "Position", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary)
}
IconButton(onClick = { onAction("neighbors") }, modifier = Modifier.size(40.dp)) {
Icon(Icons.Rounded.Hub, "Neighbors", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary)
}
IconButton(onClick = { onAction("userinfo") }, modifier = Modifier.size(40.dp)) {
Icon(Icons.Rounded.PersonSearch, "User Info", Modifier.size(20.dp), MaterialTheme.colorScheme.outline)
}
IconButton(onClick = { onAction("connstatus") }, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Rounded.SignalCellularAlt,
"Conn Status",
Modifier.size(20.dp),
MaterialTheme.colorScheme.outline,
)
}
if (isOnline) {
Icon(
imageVector = Icons.Rounded.Router,
contentDescription = "Online",
tint = MaterialTheme.colorScheme.StatusGreen.copy(alpha = 0.5f),
modifier = Modifier.padding(start = 8.dp).size(20.dp),
)
}
}
}
@Composable
private fun ActionButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) {
val scope = rememberCoroutineScope()
TitledCard(title = "Device Controls") {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
GpsButtons(viewModel, snackbarHostState)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
viewModel.rebootLocalDevice()
scope.launch { snackbarHostState.showSnackbar("Reboot Requested") }
},
shape = MaterialTheme.shapes.medium,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Icon(imageVector = Icons.Rounded.RestartAlt, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Reboot Radio")
}
}
}
}
@Composable
private fun GpsButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) {
val scope = rememberCoroutineScope()
val colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
modifier = Modifier.weight(1f),
onClick = {
viewModel.startProvideLocation()
scope.launch { snackbarHostState.showSnackbar("GPS Sharing Started") }
},
shape = MaterialTheme.shapes.medium,
colors = colors,
) {
Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Start GPS", style = MaterialTheme.typography.labelLarge)
}
Button(
modifier = Modifier.weight(1f),
onClick = {
viewModel.stopProvideLocation()
scope.launch { snackbarHostState.showSnackbar("GPS Sharing Stopped") }
},
shape = MaterialTheme.shapes.medium,
colors = colors,
) {
Icon(imageVector = Icons.Rounded.GpsOff, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Stop GPS", style = MaterialTheme.typography.labelLarge)
}
}
}

View File

@@ -0,0 +1,292 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.meshtastic.android.meshserviceexample
import android.content.Intent
import android.os.Build
import android.os.Parcelable
import android.os.RemoteException
import android.util.Log
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.service.IMeshService
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.TelemetryProtos
import kotlin.random.Random
private const val TAG = "MeshServiceViewModel"
/** ViewModel for MeshServiceExample. Handles interaction with IMeshService AIDL and manages UI state. */
@Suppress("TooManyFunctions")
class MeshServiceViewModel : ViewModel() {
private var meshService: IMeshService? = null
private val _myNodeInfo = MutableStateFlow<MyNodeInfo?>(null)
val myNodeInfo: StateFlow<MyNodeInfo?> = _myNodeInfo.asStateFlow()
private val _myId = MutableStateFlow<String?>(null)
val myId: StateFlow<String?> = _myId.asStateFlow()
private val _nodes = MutableStateFlow<List<NodeInfo>>(emptyList())
val nodes: StateFlow<List<NodeInfo>> = _nodes.asStateFlow()
private val _serviceConnectionStatus = MutableStateFlow(false)
val serviceConnectionStatus: StateFlow<Boolean> = _serviceConnectionStatus.asStateFlow()
private val _message = MutableStateFlow("")
val message: StateFlow<String> = _message.asStateFlow()
private val _connectionState = MutableStateFlow("UNKNOWN")
val connectionState: StateFlow<String> = _connectionState.asStateFlow()
fun onServiceConnected(service: IMeshService?) {
meshService = service
_serviceConnectionStatus.value = true
updateAllData()
}
fun onServiceDisconnected() {
meshService = null
_serviceConnectionStatus.value = false
}
private fun updateAllData() {
requestMyNodeInfo()
requestNodes()
updateConnectionState()
updateMyId()
}
fun updateMyId() {
meshService?.let {
try {
_myId.value = it.myId
} catch (e: RemoteException) {
Log.e(TAG, "Failed to get MyId", e)
}
}
}
fun updateConnectionState() {
meshService?.let {
try {
_connectionState.value = it.connectionState() ?: "UNKNOWN"
} catch (e: RemoteException) {
Log.e(TAG, "Failed to get connection state", e)
}
}
}
fun sendMessage(text: String) {
meshService?.let { service ->
try {
val packet =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = text.toByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
from = DataPacket.ID_LOCAL,
time = System.currentTimeMillis(),
id = service.packetId, // Correctly sync with radio's ID
status = MessageStatus.UNKNOWN,
hopLimit = 3,
channel = 0,
wantAck = true,
)
service.send(packet)
Log.d(TAG, "Message sent successfully, assigned ID: ${packet.id}")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to send message", e)
}
} ?: Log.w(TAG, "MeshService is not bound, cannot send message")
}
fun requestMyNodeInfo() {
meshService?.let {
try {
_myNodeInfo.value = it.myNodeInfo
} catch (e: RemoteException) {
Log.e(TAG, "Failed to get MyNodeInfo", e)
}
}
}
fun requestNodes() {
meshService?.let {
try {
_nodes.value = it.nodes ?: emptyList()
} catch (e: RemoteException) {
Log.e(TAG, "Failed to get nodes", e)
}
}
}
fun startProvideLocation() {
try {
meshService?.startProvideLocation()
} catch (e: RemoteException) {
Log.e(TAG, "Failed to start providing location", e)
}
}
fun stopProvideLocation() {
try {
meshService?.stopProvideLocation()
} catch (e: RemoteException) {
Log.e(TAG, "Failed to stop providing location", e)
}
}
fun requestTraceroute(nodeNum: Int) {
meshService?.let {
try {
it.requestTraceroute(Random.nextInt(), nodeNum)
Log.i(TAG, "Traceroute requested for node $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request traceroute", e)
}
}
}
fun requestTelemetry(nodeNum: Int) {
meshService?.let {
try {
it.requestTelemetry(Random.nextInt(), nodeNum, TelemetryProtos.Telemetry.DEVICE_METRICS_FIELD_NUMBER)
Log.i(TAG, "Telemetry requested for node $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request telemetry", e)
}
}
}
fun requestNeighborInfo(nodeNum: Int) {
meshService?.let {
try {
it.requestNeighborInfo(Random.nextInt(), nodeNum)
Log.i(TAG, "Neighbor info requested for node $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request neighbor info", e)
}
}
}
fun requestPosition(nodeNum: Int) {
meshService?.let {
try {
it.requestPosition(nodeNum, Position(0.0, 0.0, 0))
Log.i(TAG, "Position requested for node $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request position", e)
}
}
}
fun requestUserInfo(nodeNum: Int) {
meshService?.let {
try {
it.requestUserInfo(nodeNum)
Log.i(TAG, "User info requested for node $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request user info", e)
}
}
}
fun requestDeviceConnectionStatus(nodeNum: Int) {
meshService?.let {
try {
it.getDeviceConnectionStatus(Random.nextInt(), nodeNum)
Log.i(TAG, "Device connection status requested for node $nodeNum")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request device connection status", e)
}
}
}
fun rebootLocalDevice() {
meshService?.let {
try {
it.requestReboot(Random.nextInt(), 0)
Log.w(TAG, "Local reboot requested!")
} catch (e: RemoteException) {
Log.e(TAG, "Failed to request reboot", e)
}
}
}
fun handleIncomingIntent(intent: Intent) {
val action = intent.action ?: return
Log.d(TAG, "Received broadcast: $action")
when (action) {
"com.geeksville.mesh.NODE_CHANGE" -> handleNodeChange(intent)
"com.geeksville.mesh.CONNECTION_CHANGED",
"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.")) {
handleReceivedPacket(action, intent)
}
}
}
private fun handleNodeChange(intent: Intent) {
val nodeInfo = intent.getParcelableCompat("com.geeksville.mesh.NodeInfo", NodeInfo::class.java)
nodeInfo?.let { ni ->
Log.d(TAG, "Node updated: ${ni.num}")
_nodes.value =
_nodes.value.toMutableList().apply {
val index = indexOfFirst { it.num == ni.num }
if (index != -1) set(index, ni) else add(ni)
}
}
}
private fun handleMessageStatus(intent: Intent) {
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")
}
private fun handleReceivedPacket(action: String, intent: Intent) {
val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java) ?: return
if (packet.dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) {
val receivedText = packet.bytes?.let { bytes -> String(bytes) } ?: ""
_message.value = "From ${packet.from}: $receivedText"
} else {
_message.value = "Received port ${action.substringAfterLast(".")} packet"
}
}
private fun <T : Parcelable> Intent.getParcelableCompat(key: String, clazz: Class<T>): T? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(key, clazz)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(key)
}
}

View File

@@ -1,68 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Meshtastic Node Info"
android:textSize="24sp"
app:layout_constraintBottom_toTopOf="@+id/mainTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/statusImageView"
android:layout_width="25px"
android:layout_height="25px"
android:src="@android:color/holo_red_light"
app:layout_constraintBottom_toTopOf="@+id/titleTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/mainTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/sendBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/send_hello_message"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.852" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025 Meshtastic LLC
~
@@ -17,5 +18,4 @@
<resources>
<string name="app_name">MeshServiceExample</string>
<string name="send_hello_message">Send Hello Message</string>
</resources>
</resources>

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.meshtastic.android.meshserviceexample
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.service.testing.FakeIMeshService
/** Unit tests for [MeshServiceViewModel] using the [FakeIMeshService] test harness. */
class MeshServiceViewModelTest {
@Test
fun `test service connection updates status`() {
val viewModel = MeshServiceViewModel()
val fakeService = FakeIMeshService()
viewModel.onServiceConnected(fakeService)
assertTrue(viewModel.serviceConnectionStatus.value)
assertEquals("fake_id", viewModel.myId.value)
assertEquals("CONNECTED", viewModel.connectionState.value)
}
@Test
fun `test service disconnection updates status`() {
val viewModel = MeshServiceViewModel()
viewModel.onServiceConnected(FakeIMeshService())
viewModel.onServiceDisconnected()
assertEquals(false, viewModel.serviceConnectionStatus.value)
}
}