mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-04 22:23:47 -04:00
feat(service): Overhaul MeshServiceExample (#4263)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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?) {}
|
||||
}
|
||||
@@ -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?) {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user