The :feature:widget module is an Android-only Jetpack Glance home-screen widget (API 26+). It exposes a single widget — LocalStatsWidget — that displays live mesh statistics sourced from the running Meshtastic service.
Target: Android only (not KMP — uses meshtastic.android.library + meshtastic.android.library.compose convention plugins)
- Display live mesh statistics on the Android home screen via Jetpack Glance
- Combine four reactive data sources into a single
StateFlow<LocalStatsWidgetUiState>for efficient re-renders - Support three responsive widget sizes: small square, wide rectangle, and large square
- Fulfil the
AppWidgetUpdaterinterface from:core:repositoryso the mesh service can trigger widget refreshes without depending on Android widget APIs
src/main/kotlin/org/meshtastic/feature/widget/
├── LocalStatsWidget.kt ← GlanceAppWidget, 3 responsive sizes
├── LocalStatsWidgetReceiver.kt ← GlanceAppWidgetReceiver
├── LocalStatsWidgetState.kt ← LocalStatsWidgetUiState + StateProvider
├── AndroidAppWidgetUpdater.kt ← implements AppWidgetUpdater (core:repository)
├── RefreshLocalStatsAction.kt ← GlanceActionCallback (refresh button)
└── di/
└── FeatureWidgetModule.kt
The widget shows the following information when connected:
| Section | Data shown |
|---|---|
| Node identity | Short name chip with node colours |
| Battery | Level percentage + progress bar |
| Channel utilization | Percentage + progress bar |
| Air utilization | Percentage + progress bar |
| Traffic | TX / RX packet counts, duplicates, relay TX, relay cancelled, dropped, bad RX |
| Diagnostics | Noise floor (dBm), free heap / total heap |
| Footer | Online nodes / total nodes, device uptime, last-updated timestamp |
When disconnected, the widget shows a "Not Connected" state with the last-known node name.
data class LocalStatsWidgetUiState(
val connectionState: ConnectionState,
val isConnecting: Boolean,
val showContent: Boolean,
// Node identity
val nodeShortName: String?,
val nodeColors: Pair<Int, Int>?,
// Battery
val batteryLevel: Int?,
val hasBattery: Boolean,
// Utilization
val channelUtilization: Float,
val airUtilization: Float,
// Traffic counters
val numPacketsTx: Int, val numPacketsRx: Int,
val numRxDupe: Int, val numTxRelay: Int, val numTxRelayCanceled: Int,
val numTxDropped: Int, val numPacketsRxBad: Int,
// Diagnostics
val noiseFloor: Int?,
val heapFreeBytes: Long, val heapTotalBytes: Long,
// Footer
val totalNodes: Int, val onlineNodes: Int,
val uptimeSecs: Int?,
val updateTimeMillis: Long,
)Koin @Single that eagerly combines four repository flows:
ServiceRepository.connectionStateNodeRepository.nodeDBbyNum(online/total counts)NodeRepository.localStatsNodeRepository.ourNodeInfo
val state: StateFlow<LocalStatsWidgetUiState>class LocalStatsWidget : GlanceAppWidget(), KoinComponent {
override val sizeMode = SizeMode.Responsive(
setOf(DpSize(100.dp, 100.dp), DpSize(250.dp, 100.dp), DpSize(250.dp, 250.dp))
)
}Tapping the widget opens the app. The refresh button fires RefreshLocalStatsAction, which calls AppWidgetUpdater.updateAll().
The mesh service holds a reference to AppWidgetUpdater (defined in :core:repository) without depending on Android widget APIs. AndroidAppWidgetUpdater in this module provides the concrete implementation:
class AndroidAppWidgetUpdater : AppWidgetUpdater {
override suspend fun updateAll() {
LocalStatsWidget().updateAll(context)
}
}This keeps the service layer platform-agnostic while still enabling widget refresh on data changes.
feature:widget (Android only)
├── core:common, core:model, core:resources, core:repository
├── androidx.glance.appwidget, glance.material3, glance.preview
└── compose.multiplatform.ui (LocalConfiguration, LocalDensity)
graph TB
:feature:widget[widget]:::android-feature
:feature:widget -.-> :core:common
:feature:widget -.-> :core:model
:feature:widget -.-> :core:resources
:feature:widget -.-> :core:repository
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;