From 9970d3152091b8bcc7eaff23cb20c9c817d9027a Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 25 Feb 2026 13:39:00 -0600
Subject: [PATCH] feat(widget): Add Local Stats glance widget (#4642)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/build.gradle.kts | 5 +
app/src/main/AndroidManifest.xml | 11 +
.../geeksville/mesh/MeshUtilApplication.kt | 48 ++
.../mesh/service/MeshConnectionManager.kt | 19 +
.../service/MeshServiceNotificationsImpl.kt | 31 +-
.../mesh/widget/LocalStatsWidget.kt | 412 ++++++++++++++++++
.../mesh/widget/LocalStatsWidgetReceiver.kt | 26 ++
.../mesh/widget/LocalStatsWidgetState.kt | 251 +++++++++++
.../mesh/widget/RefreshLocalStatsAction.kt | 52 +++
app/src/main/res/drawable/ic_refresh.xml | 27 ++
.../res/layout/widget_local_stats_preview.xml | 42 ++
.../main/res/xml/local_stats_widget_info.xml | 25 ++
.../mesh/service/MeshConnectionManagerTest.kt | 12 +
.../LocalStatsWidgetStateProviderTest.kt | 119 +++++
.../core/data/repository/NodeRepository.kt | 18 +-
.../data/repository/NodeRepositoryTest.kt | 11 +-
.../core/datastore/LocalStatsDataSource.kt | 48 ++
.../core/datastore/di/DataStoreModule.kt | 14 +
.../serializer/LocalStatsSerializer.kt | 40 ++
.../meshtastic/core/resources/ContextExt.kt | 41 +-
.../composeResources/values/strings.xml | 9 +-
.../ui/contact/AdaptiveContactsScreen.kt | 14 +-
gradle/libs.versions.toml | 5 +
23 files changed, 1256 insertions(+), 24 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
create mode 100644 app/src/main/res/drawable/ic_refresh.xml
create mode 100644 app/src/main/res/layout/widget_local_stats_preview.xml
create mode 100644 app/src/main/res/xml/local_stats_widget_info.xml
create mode 100644 app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt
create mode 100644 core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt
create mode 100644 core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1743e37bc..2a740864b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -243,6 +243,9 @@ dependencies {
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.ui.text)
+ implementation(libs.androidx.glance.appwidget)
+ implementation(libs.androidx.glance.appwidget.preview)
+ implementation(libs.androidx.glance.material3)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -268,6 +271,7 @@ dependencies {
implementation(libs.nordic.common.ui)
debugImplementation(libs.androidx.compose.ui.test.manifest)
+ debugImplementation(libs.androidx.glance.preview)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
@@ -293,6 +297,7 @@ dependencies {
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.androidx.glance.appwidget)
}
aboutLibraries {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 83a745521..64d43a759 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -241,6 +241,17 @@
+
+
+
+
+
+
+
= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ applicationScope.launch {
+ suspend fun pushPreview() {
+ try {
+ Logger.i { "Pushing generated widget preview..." }
+ val result =
+ GlanceAppWidgetManager(this@MeshUtilApplication)
+ .setWidgetPreviews(
+ LocalStatsWidgetReceiver::class,
+ intSetOf(AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN),
+ )
+ Logger.i { "setWidgetPreviews result: $result" }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Failed to set widget preview" }
+ }
+ }
+
+ pushPreview()
+
+ val entryPoint =
+ EntryPointAccessors.fromApplication(
+ this@MeshUtilApplication,
+ com.geeksville.mesh.widget.LocalStatsWidget.LocalStatsWidgetEntryPoint::class.java,
+ )
+ try {
+ // Wait for real data for up to 30 seconds before pushing an updated preview
+ withTimeout(30.seconds) {
+ entryPoint.widgetStateProvider().state.first { it.showContent && it.nodeShortName != null }
+ }
+
+ Logger.i { "Real node data acquired. Pushing updated widget preview." }
+ pushPreview()
+ } catch (e: TimeoutCancellationException) {
+ Logger.i(e) { "Timed out waiting for real node data for widget preview." }
+ }
+ }
+ }
+
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress) }
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
index ec3f2bfa3..bd777c538 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -17,17 +17,23 @@
package com.geeksville.mesh.service
import android.app.Notification
+import android.content.Context
+import androidx.glance.appwidget.updateAll
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.RadioInterfaceService
+import com.geeksville.mesh.widget.LocalStatsWidget
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
@@ -61,6 +67,7 @@ import kotlin.time.DurationUnit
class MeshConnectionManager
@Inject
constructor(
+ @ApplicationContext private val context: Context,
private val radioInterfaceService: RadioInterfaceService,
private val connectionStateHolder: ConnectionStateHandler,
private val serviceBroadcasts: MeshServiceBroadcasts,
@@ -82,6 +89,7 @@ constructor(
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
+ @OptIn(FlowPreview::class)
fun start(scope: CoroutineScope) {
this.scope = scope
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
@@ -89,6 +97,16 @@ constructor(
// Ensure notification title and content stay in sync with state changes
connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
+ // Kickstart the widget composition. The widget internally uses collectAsState()
+ // and its own sampled StateFlow to drive updates automatically without excessive IPC and recreation.
+ scope.launch {
+ try {
+ LocalStatsWidget().updateAll(context)
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
+ }
+ }
+
nodeRepository.myNodeInfo
.onEach { myNodeEntity ->
locationRequestsJob?.cancel()
@@ -286,6 +304,7 @@ constructor(
}
fun updateTelemetry(telemetry: Telemetry) {
+ telemetry.local_stats?.let { nodeRepository.updateLocalStats(it) }
updateStatusNotification(telemetry)
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
index 67447d628..6128caaf6 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -61,6 +61,8 @@ import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_battery
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
import org.meshtastic.core.resources.local_stats_dropped
+import org.meshtastic.core.resources.local_stats_heap
+import org.meshtastic.core.resources.local_stats_heap_value
import org.meshtastic.core.resources.local_stats_nodes
import org.meshtastic.core.resources.local_stats_noise
import org.meshtastic.core.resources.local_stats_relays
@@ -81,6 +83,7 @@ import org.meshtastic.core.resources.meshtastic_service_notifications
import org.meshtastic.core.resources.meshtastic_waypoints_notifications
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.core.resources.no_local_stats
+import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.you
import org.meshtastic.core.service.MeshServiceNotifications
@@ -312,7 +315,10 @@ constructor(
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
}
if (cachedLocalStats == null) {
- cachedLocalStats = entity.deviceTelemetry.local_stats
+ // Fallback to DB stats if repository hasn't received any fresh ones yet
+ cachedLocalStats =
+ repo.localStats.value.takeIf { it.uptime_seconds != 0 }
+ ?: entity.deviceTelemetry.local_stats
}
}
}
@@ -855,11 +861,26 @@ constructor(
private fun LocalStats.formatToString(batteryLevel: Int? = null): String {
val parts = mutableListOf()
- batteryLevel?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
+ batteryLevel?.let {
+ if (it > MAX_BATTERY_LEVEL) {
+ parts.add(BULLET + getString(Res.string.powered))
+ } else {
+ parts.add(BULLET + getString(Res.string.local_stats_battery, it))
+ }
+ }
parts.add(BULLET + getString(Res.string.local_stats_nodes, num_online_nodes, num_total_nodes))
parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(uptime_seconds)))
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization, air_util_tx))
+ if (heap_free_bytes > 0 || heap_total_bytes > 0) {
+ parts.add(
+ BULLET +
+ getString(Res.string.local_stats_heap) +
+ ": " +
+ getString(Res.string.local_stats_heap_value, heap_free_bytes, heap_total_bytes),
+ )
+ }
+
// Traffic Stats
if (num_packets_tx > 0 || num_packets_rx > 0) {
parts.add(BULLET + getString(Res.string.local_stats_traffic, num_packets_tx, num_packets_rx, num_rx_dupe))
@@ -887,7 +908,11 @@ constructor(
val parts = mutableListOf()
battery_level?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
uptime_seconds?.let { parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(it))) }
- parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f))
+ if (channel_utilization != null || air_util_tx != null) {
+ parts.add(
+ BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f),
+ )
+ }
return parts.joinToString("\n")
}
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
new file mode 100644
index 000000000..2be3f1878
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
@@ -0,0 +1,412 @@
+/*
+ * 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 com.geeksville.mesh.widget
+
+import android.annotation.SuppressLint
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.CircularProgressIndicator
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.LinearProgressIndicator
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.components.CircleIconButton
+import androidx.glance.appwidget.components.Scaffold
+import androidx.glance.appwidget.components.TitleBar
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.air_utilization
+import org.meshtastic.core.resources.battery
+import org.meshtastic.core.resources.channel_utilization
+import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.resources.meshtastic_app_name
+import org.meshtastic.core.resources.nodes
+import org.meshtastic.core.resources.refresh
+import org.meshtastic.core.resources.updated
+import org.meshtastic.core.resources.uptime
+import org.meshtastic.core.service.ConnectionState
+
+class LocalStatsWidget : GlanceAppWidget() {
+
+ override val sizeMode: SizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
+ override val previewSizeMode: androidx.glance.appwidget.PreviewSizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface LocalStatsWidgetEntryPoint {
+ fun widgetStateProvider(): LocalStatsWidgetStateProvider
+ }
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
+ val stateProvider = entryPoint.widgetStateProvider()
+
+ provideContent {
+ val state by stateProvider.state.collectAsState()
+ WidgetContent(state)
+ }
+ }
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
+ val stateProvider = entryPoint.widgetStateProvider()
+ val currentState = stateProvider.state.value
+
+ val stateToRender =
+ if (currentState.showContent && currentState.nodeShortName != null) {
+ currentState
+ } else {
+ createMockWidgetState()
+ }
+ provideContent { WidgetContent(stateToRender) }
+ }
+
+ @Composable
+ internal fun WidgetContent(state: LocalStatsWidgetUiState) {
+ val context = LocalContext.current
+ CompositionLocalProvider(
+ androidx.compose.ui.platform.LocalContext provides context,
+ LocalConfiguration provides context.resources.configuration,
+ LocalDensity provides Density(context.resources.displayMetrics.density),
+ ) {
+ GlanceTheme {
+ Scaffold(
+ titleBar = {
+ TitleBar(
+ startIcon = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
+ title = state.appName,
+ actions = {
+ CircleIconButton(
+ imageProvider = ImageProvider(com.geeksville.mesh.R.drawable.ic_refresh),
+ contentDescription = state.refreshLabel,
+ onClick = actionRunCallback(),
+ backgroundColor = null,
+ )
+ },
+ )
+ },
+ modifier =
+ GlanceModifier.fillMaxSize().clickable(actionStartActivity()),
+ ) {
+ if (state.showContent) {
+ FullStatsContent(state)
+ } else {
+ Disconnected(state)
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun FullStatsContent(state: LocalStatsWidgetUiState) {
+ val size = LocalSize.current
+ val isNarrow = size.width < 160.dp
+ val isShort = size.height < 110.dp
+ val isSmall = isNarrow || isShort
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ // Main Stats Container
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ // Summary Header: Node Chip + Battery
+ Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ state.nodeShortName?.let { name ->
+ state.nodeColors?.let { colors -> NodeChip(shortName = name, colors = colors) }
+ }
+ Spacer(GlanceModifier.width(8.dp))
+ StatRow(
+ label = state.batteryLabel,
+ value = state.batteryValue,
+ progress = state.batteryProgress,
+ isSmall = isSmall,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ }
+
+ Spacer(GlanceModifier.height(2.dp))
+
+ // Utilization Stats
+
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ StatRow(
+ label = state.channelUtilizationLabel,
+ value = state.channelUtilizationValue,
+ progress = state.channelUtilizationProgress,
+ isSmall = isSmall,
+ modifier = GlanceModifier.defaultWeight().padding(end = 4.dp),
+ )
+ StatRow(
+ label = state.airUtilizationLabel,
+ value = state.airUtilizationValue,
+ progress = state.airUtilizationProgress,
+ isSmall = isSmall,
+ modifier = GlanceModifier.defaultWeight().padding(start = 4.dp),
+ )
+ }
+
+ // Detailed Traffic/Relay Stats
+ Spacer(GlanceModifier.height(2.dp))
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ state.trafficText?.let { StatText(it, isSmall) }
+ state.relayText?.let { StatText(it, isSmall) }
+ state.diagnosticsText?.let { StatText(it, isSmall) }
+ state.heapText?.let {
+ val heapProgress =
+ if (state.heapTotalBytes > 0) {
+ state.heapFreeBytes.toFloat() / state.heapTotalBytes
+ } else {
+ 0f
+ }
+ StatRow(it, state.heapValue, heapProgress, isSmall)
+ }
+ }
+ }
+
+ // Footer (Nodes + Uptime - Pinned to bottom)
+ Footer(state)
+ }
+ }
+
+ @Composable
+ private fun StatText(text: String, isSmall: Boolean) {
+ Text(
+ text = text,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = if (isSmall) 9.sp else 10.sp),
+ modifier = GlanceModifier.fillMaxWidth(),
+ )
+ }
+
+ @Composable
+ private fun Disconnected(state: LocalStatsWidgetUiState) {
+ Column(
+ modifier = GlanceModifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (state.isConnecting) {
+ CircularProgressIndicator(modifier = GlanceModifier.size(24.dp))
+ } else {
+ Image(
+ provider = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
+ contentDescription = null,
+ modifier = GlanceModifier.size(32.dp),
+ )
+ }
+ Text(
+ text = state.statusText,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+
+ @Composable
+ private fun Footer(state: LocalStatsWidgetUiState) {
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().padding(top = 2.dp, bottom = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.Start) {
+ Text(
+ text = state.nodesLabel,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
+ )
+ Text(
+ text = state.nodeCountText,
+ maxLines = 1,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
+ Text(
+ text = state.uptimeLabel,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
+ )
+ Text(
+ text = state.uptimeText,
+ maxLines = 1,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+ Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ val footerText =
+ if (state.updatedLabel.isNotEmpty()) {
+ "${state.updatedLabel} ${state.updatedText}"
+ } else {
+ state.updatedText
+ }
+ Text(
+ text = footerText,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 8.sp),
+ modifier = GlanceModifier.padding(bottom = 2.dp),
+ maxLines = 1,
+ )
+ }
+ }
+ }
+
+ @SuppressLint("RestrictedApi")
+ @Composable
+ private fun NodeChip(shortName: String, colors: Pair, modifier: GlanceModifier = GlanceModifier) {
+ val (fg, bg) = colors
+ Row(
+ modifier =
+ modifier
+ .width(64.dp)
+ .background(Color(bg))
+ .cornerRadius(4.dp)
+ .padding(horizontal = 6.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = shortName,
+ style = TextStyle(color = ColorProvider(Color(fg)), fontSize = 11.sp, fontWeight = FontWeight.Bold),
+ )
+ }
+ }
+
+ @Composable
+ private fun StatRow(
+ label: String,
+ value: String?,
+ progress: Float,
+ isSmall: Boolean,
+ modifier: GlanceModifier = GlanceModifier,
+ ) {
+ Column(modifier = modifier.padding(vertical = 2.dp)) {
+ Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = label,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = if (isSmall) 10.sp else 11.sp,
+ ),
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ value?.let {
+ Text(
+ text = it,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+ Spacer(GlanceModifier.height(2.dp))
+ LinearProgressIndicator(
+ progress = progress,
+ modifier = GlanceModifier.fillMaxWidth().height(4.dp).cornerRadius(2.dp),
+ color = GlanceTheme.colors.primary,
+ backgroundColor = GlanceTheme.colors.surfaceVariant,
+ )
+ }
+ }
+
+ companion object {
+ private val SMALL_SQUARE = DpSize(100.dp, 100.dp)
+ private val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp)
+ private val BIG_SQUARE = DpSize(250.dp, 250.dp)
+
+ private val RESPONSIVE_SIZES = setOf(SMALL_SQUARE, HORIZONTAL_RECTANGLE, BIG_SQUARE)
+ }
+}
+
+internal suspend fun createMockWidgetState() = LocalStatsWidgetUiState(
+ connectionState = ConnectionState.Connected,
+ showContent = true,
+ appName = getStringSuspend(Res.string.meshtastic_app_name),
+ nodesLabel = getStringSuspend(Res.string.nodes),
+ uptimeLabel = getStringSuspend(Res.string.uptime),
+ updatedLabel = getStringSuspend(Res.string.updated),
+ refreshLabel = getStringSuspend(Res.string.refresh),
+ nodeShortName = "ME",
+ nodeColors = 0xFFFFFFFF.toInt() to 0xFF000000.toInt(),
+ batteryLabel = getStringSuspend(Res.string.battery),
+ batteryValue = "85%",
+ batteryProgress = 0.85f,
+ channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
+ channelUtilizationValue = "18.5%",
+ channelUtilizationProgress = 0.185f,
+ airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
+ airUtilizationValue = "3.2%",
+ airUtilizationProgress = 0.032f,
+ trafficText = "TX: 145 | RX: 892 | D: 42",
+ nodeCountText = "2/3",
+ uptimeText = "2d 0h",
+ updatedText = "5m ago",
+)
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.kt
new file mode 100644
index 000000000..39719efb4
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.kt
@@ -0,0 +1,26 @@
+/*
+ * 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 com.geeksville.mesh.widget
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class LocalStatsWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = LocalStatsWidget()
+}
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
new file mode 100644
index 000000000..7d6dea60b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -0,0 +1,251 @@
+/*
+ * 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 com.geeksville.mesh.widget
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.util.formatUptime
+import org.meshtastic.core.model.util.onlineTimeThreshold
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.air_utilization
+import org.meshtastic.core.resources.battery
+import org.meshtastic.core.resources.channel_utilization
+import org.meshtastic.core.resources.connecting
+import org.meshtastic.core.resources.device_sleeping
+import org.meshtastic.core.resources.disconnected
+import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.resources.local_stats_bad
+import org.meshtastic.core.resources.local_stats_diagnostics_prefix
+import org.meshtastic.core.resources.local_stats_dropped
+import org.meshtastic.core.resources.local_stats_heap
+import org.meshtastic.core.resources.local_stats_heap_value
+import org.meshtastic.core.resources.local_stats_noise
+import org.meshtastic.core.resources.local_stats_relays
+import org.meshtastic.core.resources.local_stats_traffic
+import org.meshtastic.core.resources.local_stats_updated_at
+import org.meshtastic.core.resources.meshtastic_app_name
+import org.meshtastic.core.resources.nodes
+import org.meshtastic.core.resources.powered
+import org.meshtastic.core.resources.refresh
+import org.meshtastic.core.resources.updated
+import org.meshtastic.core.resources.uptime
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.LocalStats
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class LocalStatsWidgetUiState(
+ val connectionState: ConnectionState = ConnectionState.Disconnected,
+ // Rendering data
+ val statusText: String = "",
+ val isConnecting: Boolean = false,
+ val showContent: Boolean = false,
+
+ // Static Strings (Resolved in provider for Glance stability)
+ val appName: String = "",
+ val nodesLabel: String = "",
+ val uptimeLabel: String = "",
+ val updatedLabel: String = "",
+ val refreshLabel: String = "",
+
+ // Node Identity
+ val nodeShortName: String? = null,
+ val nodeColors: Pair? = null,
+
+ // Battery
+ val batteryLabel: String = "",
+ val batteryValue: String = "",
+ val batteryProgress: Float = 0f,
+
+ // Utilization
+ val channelUtilizationLabel: String = "",
+ val channelUtilizationValue: String = "",
+ val channelUtilizationProgress: Float = 0f,
+ val airUtilizationLabel: String = "",
+ val airUtilizationValue: String = "",
+ val airUtilizationProgress: Float = 0f,
+
+ // Packet Stats Lines
+ val trafficText: String? = null,
+ val relayText: String? = null,
+ val diagnosticsText: String? = null,
+ val heapFreeBytes: Int = 0,
+ val heapTotalBytes: Int = 0,
+ val heapValue: String? = null,
+ val heapText: String? = null,
+
+ // Footer
+ val nodeCountText: String = "",
+ val uptimeText: String = "",
+ val updatedText: String = "",
+)
+
+@Singleton
+class LocalStatsWidgetStateProvider
+@Inject
+constructor(
+ nodeRepository: NodeRepository,
+ serviceRepository: ServiceRepository,
+) {
+ private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+
+ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+ val state: StateFlow =
+ combine(
+ serviceRepository.connectionState,
+ nodeRepository.nodeDBbyNum
+ .map { nodes ->
+ val online = nodes.values.count { it.lastHeard > onlineTimeThreshold() }
+ nodes.size to online
+ }
+ .distinctUntilChanged(),
+ nodeRepository.localStats,
+ nodeRepository.ourNodeInfo,
+ ) { connectionState, (totalNodes, onlineNodes), stats, localNode ->
+ StateInput(connectionState, totalNodes, onlineNodes, stats, localNode)
+ }
+ .map { input ->
+ mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
+ }
+ .distinctUntilChanged()
+ .stateIn(
+ scope = scope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = LocalStatsWidgetUiState(),
+ )
+
+ private data class StateInput(
+ val connectionState: ConnectionState,
+ val totalNodes: Int,
+ val onlineNodes: Int,
+ val stats: LocalStats,
+ val localNode: Node?,
+ )
+
+ @Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
+ private suspend fun mapToUiState(
+ connectionState: ConnectionState,
+ totalNodes: Int,
+ onlineNodes: Int,
+ stats: LocalStats,
+ localNode: Node?,
+ ): LocalStatsWidgetUiState {
+ val statusText =
+ when (connectionState) {
+ is ConnectionState.Disconnected -> getStringSuspend(Res.string.disconnected)
+ is ConnectionState.Connecting -> getStringSuspend(Res.string.connecting)
+ is ConnectionState.DeviceSleep -> getStringSuspend(Res.string.device_sleeping)
+ is ConnectionState.Connected -> ""
+ }
+
+ val metrics = localNode?.deviceMetrics
+ val batteryLevel = metrics?.battery_level ?: 0
+ val isPowered = batteryLevel > 100
+ val batteryValue = if (isPowered) getStringSuspend(Res.string.powered) else "$batteryLevel%"
+
+ val hasStats = stats.uptime_seconds != 0
+ val channelUtil = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f
+ val airUtilTx = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f
+
+ val diag = mutableListOf()
+ if (hasStats) {
+ if (stats.noise_floor != 0) {
+ diag.add(getStringSuspend(Res.string.local_stats_noise, stats.noise_floor))
+ }
+ if (stats.num_packets_rx_bad > 0) {
+ diag.add(getStringSuspend(Res.string.local_stats_bad, stats.num_packets_rx_bad))
+ }
+ if (stats.num_tx_dropped > 0) {
+ diag.add(getStringSuspend(Res.string.local_stats_dropped, stats.num_tx_dropped))
+ }
+ }
+
+ val uptimeSecs = if (hasStats) stats.uptime_seconds.toLong() else metrics?.uptime_seconds?.toLong() ?: 0L
+
+ return LocalStatsWidgetUiState(
+ connectionState = connectionState,
+ statusText = statusText,
+ isConnecting = connectionState is ConnectionState.Connecting,
+ showContent = connectionState is ConnectionState.Connected,
+ appName = getStringSuspend(Res.string.meshtastic_app_name),
+ nodesLabel = getStringSuspend(Res.string.nodes),
+ uptimeLabel = getStringSuspend(Res.string.uptime),
+ updatedLabel = getStringSuspend(Res.string.updated),
+ refreshLabel = getStringSuspend(Res.string.refresh),
+ nodeShortName = localNode?.user?.short_name,
+ nodeColors = localNode?.colors,
+ batteryLabel = getStringSuspend(Res.string.battery),
+ batteryValue = batteryValue,
+ batteryProgress = (batteryLevel / 100f).coerceIn(0f, 1f),
+ channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
+ channelUtilizationValue = "%.1f%%".format(channelUtil),
+ channelUtilizationProgress = (channelUtil / 100f).coerceIn(0f, 1f),
+ airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
+ airUtilizationValue = "%.1f%%".format(airUtilTx),
+ airUtilizationProgress = (airUtilTx / 100f).coerceIn(0f, 1f),
+ trafficText =
+ if (hasStats) {
+ getStringSuspend(
+ Res.string.local_stats_traffic,
+ stats.num_packets_tx,
+ stats.num_packets_rx,
+ stats.num_rx_dupe,
+ )
+ } else {
+ null
+ },
+ relayText =
+ stats
+ .takeIf { hasStats && (it.num_tx_relay > 0 || it.num_tx_relay_canceled > 0) }
+ ?.let {
+ getStringSuspend(Res.string.local_stats_relays, it.num_tx_relay, it.num_tx_relay_canceled)
+ },
+ diagnosticsText =
+ if (diag.isNotEmpty()) {
+ getStringSuspend(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | "))
+ } else {
+ null
+ },
+ heapFreeBytes = if (hasStats) stats.heap_free_bytes else 0,
+ heapTotalBytes = if (hasStats) stats.heap_total_bytes else 0,
+ heapValue =
+ if (hasStats) {
+ getStringSuspend(Res.string.local_stats_heap_value, stats.heap_free_bytes, stats.heap_total_bytes)
+ } else {
+ null
+ },
+ heapText = if (hasStats) getStringSuspend(Res.string.local_stats_heap) else null,
+ nodeCountText = "$onlineNodes/$totalNodes",
+ uptimeText = formatUptime(uptimeSecs.toInt()),
+ updatedText = getStringSuspend(Res.string.local_stats_updated_at, DateFormatter.formatShortDate(nowMillis)),
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
new file mode 100644
index 000000000..16d6b566e
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
@@ -0,0 +1,52 @@
+/*
+ * 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 com.geeksville.mesh.widget
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.action.ActionParameters
+import androidx.glance.appwidget.action.ActionCallback
+import com.geeksville.mesh.service.MeshCommandSender
+import com.geeksville.mesh.service.MeshNodeManager
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.model.TelemetryType
+
+class RefreshLocalStatsAction : ActionCallback {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface RefreshLocalStatsEntryPoint {
+ fun commandSender(): MeshCommandSender
+
+ fun nodeManager(): MeshNodeManager
+ }
+
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(context.applicationContext, RefreshLocalStatsEntryPoint::class.java)
+ val commandSender = entryPoint.commandSender()
+ val nodeManager = entryPoint.nodeManager()
+
+ val myNodeNum = nodeManager.myNodeNum ?: return
+
+ commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
+ commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
+ }
+}
diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 000000000..3f20873d9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_local_stats_preview.xml b/app/src/main/res/layout/widget_local_stats_preview.xml
new file mode 100644
index 000000000..49092eaa7
--- /dev/null
+++ b/app/src/main/res/layout/widget_local_stats_preview.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/local_stats_widget_info.xml b/app/src/main/res/xml/local_stats_widget_info.xml
new file mode 100644
index 000000000..da9863cd9
--- /dev/null
+++ b/app/src/main/res/xml/local_stats_widget_info.xml
@@ -0,0 +1,25 @@
+
+
+
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
index 7249600c6..c7e002ec0 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
@@ -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().updateAll(any()) } returns Unit
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(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
diff --git a/app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt b/app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt
new file mode 100644
index 000000000..3d89d10d1
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt
@@ -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 .
+ */
+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.Disconnected)
+ private val nodeDbFlow = MutableStateFlow