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

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