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

@ -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 {

View file

@ -241,6 +241,17 @@
<receiver android:name="com.geeksville.mesh.service.MarkAsReadReceiver"/>
<receiver android:name="com.geeksville.mesh.service.ReactionReceiver"/>
<receiver
android:name="com.geeksville.mesh.widget.LocalStatsWidgetReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/local_stats_widget_info" />
</receiver>
<!-- allow for plugin discovery -->
<activity
android:name="com.atakmap.app.component"

View file

@ -17,12 +17,17 @@
package com.geeksville.mesh
import android.app.Application
import android.appwidget.AppWidgetProviderInfo
import android.os.Build
import androidx.collection.intSetOf
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import com.geeksville.mesh.widget.LocalStatsWidgetReceiver
import com.geeksville.mesh.worker.MeshLogCleanupWorker
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
@ -31,8 +36,11 @@ import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
@ -40,6 +48,7 @@ import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
/**
@ -65,6 +74,45 @@ open class MeshUtilApplication :
// Schedule periodic MeshLog cleanup
scheduleMeshLogCleanup()
// Generate and publish widget preview for Android 15+ widget picker
if (Build.VERSION.SDK_INT >= 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) }

View file

@ -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)
}

View file

@ -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<String>()
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<String>()
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")
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RefreshLocalStatsAction>(),
backgroundColor = null,
)
},
)
},
modifier =
GlanceModifier.fillMaxSize().clickable(actionStartActivity<com.geeksville.mesh.MainActivity>()),
) {
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<Int, Int>, 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",
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int, Int>? = 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<LocalStatsWidgetUiState> =
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<String>()
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)),
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -0,0 +1,27 @@
<!--
~ 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/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View file

@ -0,0 +1,42 @@
<?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/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#2E2E2E"
android:orientation="vertical"
android:padding="16dp"
android:gravity="center">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_launcher_foreground"
android:contentDescription="@null" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Meshtastic Stats"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="14sp" />
</LinearLayout>

View file

@ -0,0 +1,25 @@
<?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/>.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:previewLayout="@layout/widget_local_stats_preview"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />

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)
}
}

View file

@ -42,11 +42,13 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
import javax.inject.Inject
import javax.inject.Singleton
@ -57,10 +59,11 @@ import javax.inject.Singleton
class NodeRepository
@Inject
constructor(
@ProcessLifecycle processLifecycle: Lifecycle,
@ProcessLifecycle private val processLifecycle: Lifecycle,
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
private val dispatchers: CoroutineDispatchers,
private val localStatsDataSource: LocalStatsDataSource,
) {
/** Hardware info about our local device (can be null if not connected). */
val myNodeInfo: StateFlow<MyNodeEntity?> =
@ -81,6 +84,19 @@ constructor(
val myId: StateFlow<String?>
get() = _myId
/** The latest local stats telemetry received from the locally connected node. */
val localStats: StateFlow<LocalStats> =
localStatsDataSource.localStatsFlow.stateIn(
processLifecycle.coroutineScope,
SharingStarted.Eagerly,
LocalStats(),
)
/** Update the cached local stats telemetry. */
fun updateLocalStats(stats: LocalStats) {
processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
}
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
val nodeDBbyNum: StateFlow<Map<Int, Node>> =
nodeInfoReadDataSource

View file

@ -40,6 +40,7 @@ import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
@OptIn(ExperimentalCoroutinesApi::class)
@ -49,6 +50,7 @@ class NodeRepositoryTest {
private val writeDataSource: NodeInfoWriteDataSource = mockk(relaxed = true)
private val lifecycle: Lifecycle = mockk(relaxed = true)
private val lifecycleScope: LifecycleCoroutineScope = mockk()
private val localStatsDataSource: LocalStatsDataSource = mockk(relaxed = true)
private val testDispatcher = StandardTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
@ -88,7 +90,8 @@ class NodeRepositoryTest {
val myNodeNum = 12345
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
val repository =
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
@ -102,7 +105,8 @@ class NodeRepositoryTest {
val remoteNodeNum = 67890
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
val repository =
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
@ -117,7 +121,8 @@ class NodeRepositoryTest {
val targetNodeNum = 111
myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum)
val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
val repository =
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
// Initially should be mapped to LOCAL because it matches

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.datastore
import androidx.datastore.core.DataStore
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import okio.IOException
import org.meshtastic.proto.LocalStats
import javax.inject.Inject
import javax.inject.Singleton
/** Class that handles saving and retrieving [LocalStats] data. */
@Singleton
class LocalStatsDataSource @Inject constructor(private val localStatsStore: DataStore<LocalStats>) {
val localStatsFlow: Flow<LocalStats> =
localStatsStore.data.catch { exception ->
if (exception is IOException) {
Logger.e { "Error reading LocalStats: ${exception.message}" }
emit(LocalStats())
} else {
throw exception
}
}
suspend fun setLocalStats(stats: LocalStats) {
localStatsStore.updateData { stats }
}
suspend fun clearLocalStats() {
localStatsStore.updateData { LocalStats() }
}
}

View file

@ -43,10 +43,12 @@ import org.meshtastic.core.datastore.KEY_SHOW_IGNORED
import org.meshtastic.core.datastore.KEY_THEME
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
import javax.inject.Qualifier
import javax.inject.Singleton
@ -129,4 +131,16 @@ object DataStoreModule {
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
)
@Singleton
@Provides
fun provideLocalStatsDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalStats> = DataStoreFactory.create(
serializer = LocalStatsSerializer,
produceFile = { appContext.dataStoreFile("local_stats.pb") },
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
scope = scope,
)
}

View file

@ -0,0 +1,40 @@
/*
* 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.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import okio.IOException
import org.meshtastic.proto.LocalStats
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [LocalStats] object defined in telemetry.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object LocalStatsSerializer : Serializer<LocalStats> {
override val defaultValue: LocalStats = LocalStats()
override suspend fun readFrom(input: InputStream): LocalStats {
try {
return LocalStats.ADAPTER.decode(input)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalStats, output: OutputStream) = LocalStats.ADAPTER.encode(output, t)
}

View file

@ -18,23 +18,36 @@ package org.meshtastic.core.resources
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.getString as composeGetString
/** Retrieves a string from the [StringResource] in a blocking manner. Use primarily in non-composable code. */
fun getString(stringResource: StringResource): String = runBlocking {
org.jetbrains.compose.resources.getString(stringResource)
}
fun getString(stringResource: StringResource): String = runBlocking { composeGetString(stringResource) }
/** Retrieves a formatted string from the [StringResource] in a blocking manner. */
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
val resolvedArgs =
formatArgs.map { arg ->
if (arg is StringResource) {
getString(arg)
} else {
arg
}
}
@Suppress("SpreadOperator")
org.jetbrains.compose.resources.getString(stringResource, *resolvedArgs.toTypedArray())
getStringSuspend(stringResource, *formatArgs)
}
/** Retrieves a string from the [StringResource] in a suspending manner. */
suspend fun getStringSuspend(stringResource: StringResource): String = composeGetString(stringResource)
/** Retrieves a formatted string from the [StringResource] in a suspending manner. */
suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs: Any): String {
val resolvedArgs =
formatArgs
.map { arg ->
if (arg is StringResource) {
// Resolve nested StringResources recursively
getStringSuspend(arg)
} else {
arg
}
}
.toTypedArray()
// Compose Multiplatform doesn't fully support complex formatting like %.2f
// Fetch the raw string and format it using standard Java String.format.
val rawString = composeGetString(stringResource)
@Suppress("SpreadOperator")
return String.format(java.util.Locale.getDefault(), rawString, *resolvedArgs)
}

View file

@ -1199,10 +1199,17 @@
<string name="local_stats_nodes">Nodes: %1$d online / %2$d total</string>
<string name="local_stats_uptime">Uptime: %1$s</string>
<string name="local_stats_utilization">ChUtil: %1$.2f%% | AirTX: %2$.2f%%</string>
<string name="local_stats_traffic">Traffic: TX %1$d / RX %2$d (Dupes: %3$d)</string>
<string name="local_stats_traffic">Traffic: TX %1$d / RX %2$d (D: %3$d)</string>
<string name="local_stats_relays">Relays: %1$d (Canceled: %2$d)</string>
<string name="local_stats_diagnostics_prefix">Diagnostics: %1$s</string>
<string name="local_stats_noise">Noise %1$d dBm</string>
<string name="local_stats_bad">Bad %1$d</string>
<string name="local_stats_dropped">Dropped %1$d</string>
<string name="local_stats_heap">Heap</string>
<string name="local_stats_heap_value">%1$d / %2$d</string>
<string name="local_stats_updated_at">%1$s</string>
<string name="powered">Powered</string>
<string name="meshtastic_stats">Meshtastic Stats</string>
<string name="refresh">Refresh</string>
<string name="updated">Updated</string>
</resources>

View file

@ -17,7 +17,7 @@
package org.meshtastic.feature.messaging.ui.contact
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -43,6 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -93,7 +94,16 @@ fun AdaptiveContactsScreen(
}
}
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
PredictiveBackHandler(
enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
) { progress ->
try {
progress.collect { /* Predictive back progress could be used here to drive UI if scaffold supported it */ }
handleBack()
} catch (_: CancellationException) {
// Gesture cancelled
}
}
LaunchedEffect(initialContactKey) {
if (initialContactKey != null) {

View file

@ -9,6 +9,7 @@ androidxComposeMaterial3Adaptive = "1.2.0"
androidxHilt = "1.3.0"
androidxTracing = "1.10.3"
datastore = "1.2.0"
glance = "1.2.0-rc01"
lifecycle = "2.10.0"
navigation = "2.9.7"
navigation3 = "1.0.1"
@ -77,6 +78,10 @@ androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", versi
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-emoji2-emojipicker = { module = "androidx.emoji2:emoji2-emojipicker", version = "1.6.0" }
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
androidx-glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" }
androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance" }
androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHilt" }
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidxHilt" }