From 4e2c429180439a790f6550a7a302f4a2d37e307a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:58:26 -0600 Subject: [PATCH] feat(service): Overhaul MeshServiceExample (#4263) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- core/service/build.gradle.kts | 2 + .../core/service/testing/FakeIMeshService.kt | 123 +++++ .../core/service/FakeIMeshService.kt | 120 +++++ .../core/service/IMeshServiceContractTest.kt | 37 ++ mesh_service_example/build.gradle.kts | 15 +- .../meshserviceexample/MainActivity.kt | 217 +++----- .../android/meshserviceexample/MainScreen.kt | 466 ++++++++++++++++++ .../MeshServiceViewModel.kt | 292 +++++++++++ .../src/main/res/layout/activity_main.xml | 68 --- .../src/main/res/values/strings.xml | 4 +- .../MeshServiceViewModelTest.kt | 48 ++ 11 files changed, 1156 insertions(+), 236 deletions(-) create mode 100644 core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt create mode 100644 core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt create mode 100644 core/service/src/test/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt create mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt create mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt delete mode 100644 mesh_service_example/src/main/res/layout/activity_main.xml create mode 100644 mesh_service_example/src/test/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModelTest.kt diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index 28c8af0af..ef5549710 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -38,6 +38,8 @@ plugins { alias(libs.plugins.meshtastic.android.library) } configure { buildFeatures { aidl = true } namespace = "org.meshtastic.core.service" + + testOptions { unitTests.isReturnDefaultValues = true } } dependencies { diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt new file mode 100644 index 000000000..ecefd8bbc --- /dev/null +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt @@ -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 . + */ +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 = 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?) {} +} diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt b/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt new file mode 100644 index 000000000..a8611a107 --- /dev/null +++ b/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt @@ -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 . + */ +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 = 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?) {} +} diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt b/core/service/src/test/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt new file mode 100644 index 000000000..ab1956bc3 --- /dev/null +++ b/core/service/src/test/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt @@ -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 . + */ +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) + } +} diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts index 12610e8ac..33d20268b 100644 --- a/mesh_service_example/build.gradle.kts +++ b/mesh_service_example/build.gradle.kts @@ -32,18 +32,23 @@ configure { // 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) } diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt index 7dd6ca103..178b09e96 100644 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt @@ -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 . */ - 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(R.id.mainTextView) - val statusImageView = findViewById(R.id.statusImageView) + bindMeshService() - findViewById