feat(widget): Add Local Stats glance widget (#4642)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-25 13:39:00 -06:00 committed by GitHub
parent 692ad78c80
commit 9970d31520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1256 additions and 24 deletions

View file

@ -16,6 +16,9 @@
*/
package com.geeksville.mesh.service
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.coEvery
import io.mockk.every
@ -36,17 +39,20 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.ToRadio
class MeshConnectionManagerTest {
private val context: Context = mockk(relaxed = true)
private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
private val connectionStateHolder = ConnectionStateHandler()
private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
@ -72,16 +78,21 @@ class MeshConnectionManagerTest {
@Before
fun setUp() {
mockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
mockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt")
coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String"
coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } returns "Mocked String"
coEvery { any<GlanceAppWidget>().updateAll(any()) } returns Unit
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
every { nodeRepository.myNodeInfo } returns MutableStateFlow<MyNodeEntity?>(null)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow<Node?>(null)
every { nodeRepository.localStats } returns MutableStateFlow(LocalStats())
manager =
MeshConnectionManager(
context,
radioInterfaceService,
connectionStateHolder,
serviceBroadcasts,
@ -102,6 +113,7 @@ class MeshConnectionManagerTest {
@After
fun tearDown() {
unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
unmockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt")
}
@Test

View file

@ -0,0 +1,119 @@
/*
* Copyright (c) 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.geeksville.mesh.widget
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
@OptIn(ExperimentalCoroutinesApi::class)
class LocalStatsWidgetStateProviderTest {
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val nodeDbFlow = MutableStateFlow<Map<Int, Node>>(emptyMap())
private val localStatsFlow = MutableStateFlow(LocalStats())
private val ourNodeInfoFlow = MutableStateFlow<Node?>(null)
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
@Before
fun setUp() {
mockkStatic("org.meshtastic.core.resources.ContextExtKt")
mockkStatic("org.meshtastic.core.model.util.TimeUtilsKt")
coEvery { getStringSuspend(any()) } returns "Mock String"
coEvery { getStringSuspend(any(), *anyVararg()) } returns "Mock Formatted String"
every { onlineTimeThreshold() } returns 0
// Explicitly return flows from mocks
every { serviceRepository.connectionState } returns connectionStateFlow
every { nodeRepository.nodeDBbyNum } returns nodeDbFlow
every { nodeRepository.localStats } returns localStatsFlow
every { nodeRepository.ourNodeInfo } returns ourNodeInfoFlow
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `initial state reflects disconnected status`() = runTest {
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
val state = provider.state.first()
assertEquals(ConnectionState.Disconnected, state.connectionState)
assertFalse(state.showContent)
}
@Test
fun `connected state shows content and maps node info`() = runTest {
connectionStateFlow.value = ConnectionState.Connected
ourNodeInfoFlow.value =
Node(
num = 123,
user = User(short_name = "ABC"),
deviceMetrics = DeviceMetrics(battery_level = 85, channel_utilization = 12.5f),
)
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
val state =
provider.state.first { (it.connectionState == ConnectionState.Connected) && (it.nodeShortName == "ABC") }
assertTrue(state.showContent)
assertEquals("ABC", state.nodeShortName)
assertEquals("85%", state.batteryValue)
}
@Test
fun `node count and update timestamp are populated`() = runTest {
connectionStateFlow.value = ConnectionState.Connected
nodeDbFlow.value = mapOf(1 to Node(num = 1, lastHeard = 1000))
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
val state = provider.state.first { it.nodeCountText == "1/1" }
assertEquals("1/1", state.nodeCountText)
assertEquals("Mock Formatted String", state.updatedText)
}
}