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