Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 25 additions & 2 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,29 @@
android:resource="@xml/widget_local_stats_info" />
</receiver>

<receiver
android:name="org.meshtastic.feature.widget.NodeTrackerWidgetReceiver"
android:exported="false"
android:label="@string/widget_node_tracker_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_node_tracker_info" />
</receiver>

<!-- Exported for the launcher's APPWIDGET_CONFIGURE; it honors only widget ids bound to the tracker. -->
<activity
android:name="org.meshtastic.feature.widget.NodeTrackerConfigActivity"
android:excludeFromRecents="true"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>

<!--
glance-appwidget declares ActionTrampolineActivity, but nothing in Glance ever targets it. Its onCreate
calls launchTrampolineAction(intent), which require()s an "ACTION_INTENT" Intent extra, so ANY launch that
Expand All @@ -370,8 +393,8 @@
and actionRunCallback() a direct PendingIntent.getBroadcast() to ActionCallbackBroadcastReceiver.

So this is dead-but-launchable library code: an exported=false activity that crashes the process on every
launch and that our widget can never legitimately reach (LocalStatsWidget has no lazy collection, and a tap
on it dispatches its own PendingIntent directly). Same defect class as the ATAK marker below. Removing the
launch and that our widgets can never legitimately reach (neither LocalStatsWidget nor NodeTrackerWidget has a
lazy collection, and a tap on either dispatches its own PendingIntent directly). Same defect class as the ATAK marker below. Removing the
node is the whole fix - it takes the component out of the merged manifest so it cannot be started.

If glance is ever bumped, re-verify that ActionTrampolineType.ACTIVITY still has no producer before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class BundledSqliteGuardTest {
add("org.meshtastic.core.service.ConversationActionService")
add("org.meshtastic.core.nfc.MeshtasticHostApduService")
add("org.meshtastic.feature.widget.LocalStatsWidgetReceiver")
add("org.meshtastic.feature.widget.NodeTrackerWidgetReceiver")
if (BuildConfig.FLAVOR == "google") add("org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctionService")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@
<string name="doc_keywords_telemetry">telemetry,sensor,temperature,humidity,pressure,power</string>
<string name="doc_keywords_translate">translate,crowdin,localization,language,i18n,contribute</string>
<string name="doc_keywords_units">units,locale,metric,imperial,temperature,distance</string>
<string name="doc_keywords_widget">widget,home screen,local stats,glance,battery,utilization</string>
<string name="doc_keywords_widget">widget,home screen,local stats,node tracker,glance,battery,utilization</string>
<string name="doc_loading">Loading documentation…</string>
<string name="doc_no_content">No content available.</string>
<string name="doc_no_documentation">No documentation available</string>
Expand Down Expand Up @@ -2052,6 +2052,8 @@
<string name="weight">Weight</string>
<string name="what_is_meshtastic">What is Meshtastic?</string>
<string name="what_is_meshtastic_description">An open source, off-grid, decentralized, mesh network that runs on affordable, low-power radios.</string>
<string name="widget_node_tracker_choose">Choose a node to track</string>
<string name="widget_node_tracker_not_found">Node not found. Tap to choose a node.</string>
<!-- WIFI -->
<string name="wifi_config">Wi-Fi Options</string>
<string name="wifi_devices">Wi-Fi Provisioning for mPWRD-OS</string>
Expand Down
52 changes: 41 additions & 11 deletions docs/en/user/widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@
title: Home Screen Widget
parent: User Guide
nav_order: 20
last_updated: 2026-08-30
description: Add the Meshtastic home screen widget to glance at your connected radio's local stats without opening the app.
last_updated: 2026-10-20
description: Add Meshtastic home screen widgets to glance at your connected radio's local stats, or keep an eye on one node you pick, without opening the app.
aliases:
- widget
- home-screen-widget
- local-stats-widget
- node-tracker-widget
---

# Home Screen Widget

On Android, Meshtastic provides a home screen **widget** that shows live local statistics from your connected radio at a glance β€” no need to open the app.
On Android, Meshtastic provides two home screen **widgets**:

## What It Shows
- **Meshtastic** shows live local statistics from your connected radio.
- **Node Tracker** follows one node you choose: its battery, signal, when it was last heard, how many hops away it is, and how far it is from your radio.

The widget displays the **connected radio's** current local stats:
You can place as many of each as you like. Each Node Tracker remembers its own node.

## Local Stats

The **Meshtastic** widget displays the **connected radio's** current local stats:

- A **node chip** across the top, carrying the radio's short name in its own colors
- **Battery** β€” the radio's battery level, or *Powered* when running on external power
Expand All @@ -34,17 +40,41 @@ Tap the widget to open the app, or use its refresh control to request fresh stat

> ℹ️ **Note:** The values reflect the connected radio. If the radio disconnects, the widget replaces the stats with a status line β€” **Disconnected**, **Connecting**, or **Device sleeping**. It does not keep the last-known numbers on screen.

## Adding the Widget
## Node Tracker

The **Node Tracker** widget shows one node from your node list:

- The node's **chip** and **long name**
- **Battery**: the node's last reported battery level, or *Powered* on external power
- **Last heard**: the time your radio last heard the node, or the date if it was more than a day ago

Widen the widget to add a second column:

- **SNR** and **RSSI**: the signal of the node's last packet, shown only when the node is heard directly rather than through other nodes or MQTT
- **Hops Away**: how many hops away the node is, or *Direct*
- **Distance**: how far the node is from your radio. It appears only while your radio is connected and both have a position

When a node has not been heard within the last two hours, the widget dims its readings so they do not read as current. Tap the widget to open that node's details in the app.

After you disconnect from your radio in the app, the widget keeps the node's last readings on screen, dimmed, until you reconnect.

If your radio's node list does not include the node you picked, because you removed it or connected to a different radio, the widget says **Node not found**. Tap it to choose another node.

## Adding a Widget

1. Touch & hold an empty area of your Android home screen.
2. Tap **Widgets**.
3. Drag the **Meshtastic** widget to your home screen. The app ships one widget, so the picker entry is just the app name.
4. Resize it as needed β€” the layout adapts to the available space.
2. Tap **Widgets**, then find **Meshtastic** in the list.
3. Drag the **Meshtastic** (local stats) or **Node Tracker** widget to your home screen.
4. For a Node Tracker, pick the node to follow. Your favorites are listed first, then the most recently heard nodes; type in the search field to filter by name or node ID. Backing out without picking cancels the widget.
5. Resize it as needed β€” the layout adapts to the available space.

To track a different node later, touch & hold the Node Tracker and choose the widget's settings or reconfigure option. The exact wording depends on your launcher.

> ℹ️ **Note:** The widget is Android-only. It is not available on the Desktop or iOS builds.
> ℹ️ **Note:** The widgets are Android-only. They are not available on the Desktop or iOS builds.

## Related Topics

- [Node Metrics](node-metrics) β€” the full Signal Quality and Local Stats history inside the app
- [Connections](connections) β€” connect to a radio so the widget has stats to show
- [Nodes](nodes) β€” favorites, last heard, and node details
- [Connections](connections) β€” connect to a radio so the widgets have data to show
- [Local Mesh Discovery](discovery) β€” channel and airtime utilization across the mesh
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ class DefaultDocBundleLoader : DocBundleLoader {
CoreRes.string.doc_keywords_widget,
"en/user/widget.html",
20,
listOf("widget", "home-screen-widget", "local-stats-widget"),
listOf("widget", "home-screen-widget", "local-stats-widget", "node-tracker-widget"),
1700,
"widget",
),
Expand Down
46 changes: 39 additions & 7 deletions feature/widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

## Overview

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.
The `:feature:widget` module holds the **Android-only Jetpack Glance home-screen widgets** (API 26+):

- `LocalStatsWidget` shows live mesh statistics for the connected radio.
- `NodeTrackerWidget` follows one node the user picks: battery, signal, last heard, hops away and distance from our radio.

**Target: Android only** (not KMP β€” uses `meshtastic.android.library` + `meshtastic.android.library.compose` convention plugins)

## Key Responsibilities

- Display live mesh statistics on the Android home screen via Jetpack Glance
- Display live mesh statistics and a tracked node 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
- Pick the tracked node in `NodeTrackerConfigActivity`, the widget's configuration activity, and store it per instance in Glance state
- Fulfil the `AppWidgetUpdater` interface from `:core:repository` so the mesh service can trigger widget refreshes without depending on Android widget APIs

## Source Structure
Expand All @@ -20,13 +23,32 @@ src/main/kotlin/org/meshtastic/feature/widget/
β”œβ”€β”€ LocalStatsWidget.kt ← GlanceAppWidget, 3 responsive sizes
β”œβ”€β”€ LocalStatsWidgetReceiver.kt ← GlanceAppWidgetReceiver
β”œβ”€β”€ LocalStatsWidgetState.kt ← LocalStatsWidgetUiState + StateProvider
β”œβ”€β”€ NodeTrackerWidget.kt ← GlanceAppWidget, 2 responsive sizes
β”œβ”€β”€ NodeTrackerWidgetReceiver.kt ← GlanceAppWidgetReceiver
β”œβ”€β”€ NodeTrackerWidgetState.kt ← NodeTrackerWidgetState, nodeTrackerState(), DataSource
β”œβ”€β”€ NodeTrackerConfigActivity.kt ← APPWIDGET_CONFIGURE node picker (Compose, Material 3)
β”œβ”€β”€ NodeTrackerCandidates.kt ← picker filter and order
β”œβ”€β”€ WidgetNodeChip.kt ← node chip shared by both widgets
β”œβ”€β”€ AndroidAppWidgetUpdater.kt ← implements AppWidgetUpdater (core:repository)
β”œβ”€β”€ RefreshLocalStatsAction.kt ← GlanceActionCallback (refresh button)
└── di/
└── FeatureWidgetModule.kt
```

## Widget Content
## Node Tracker

Each instance stores its node number under `TRACKED_NODE_NUM_KEY` in its Glance Preferences state, and the last reading it rendered from a node DB under the `last_known_*` keys, which it shows dimmed while no radio's node DB is open. `NodeTrackerConfigActivity` writes it, from the launcher's `APPWIDGET_CONFIGURE` on placement or reconfiguration (`widgetFeatures="reconfigurable"`), or from the widget's own "Node not found" state. The activity is exported for the launcher, so it honors only widget ids bound to `NodeTrackerWidgetReceiver`, and it returns `RESULT_CANCELED` unless a node is picked.

| Size | Data shown |
|---|---|
| Small (under 200 dp wide) | Node chip, long name, battery, last heard |
| Wide | Adds SNR and RSSI (direct, non-MQTT nodes only), hops away, distance (only while connected) |

`nodeTrackerState()` maps the node DB to what one instance renders. A node not heard within the app's online window renders dimmed; the provider's 30-minute `updatePeriodMillis` re-renders a node that has gone quiet. Tapping opens the node's details through the `nodes/<num>` deep link. The picker lists favorites first, then the most recently heard, leaving out our own radio and ignored nodes.

The widget holds no lazy collection, which the `ActionTrampolineActivity` removal in `androidApp/src/main/AndroidManifest.xml` relies on.

## Local Stats Content

The widget shows the following information when connected:

Expand Down Expand Up @@ -103,28 +125,36 @@ Tapping the widget opens the app. The refresh button fires `RefreshLocalStatsAct
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:

```kotlin
// Simplified β€” the real updateAll() wraps the call in a try/catch
// Simplified β€” the real update wraps each call in a try/catch
@Single
class AndroidAppWidgetUpdater(
private val context: Context,
stateProvider: LocalStatsWidgetStateProvider,
trackerDataSource: NodeTrackerWidgetDataSource,
) : AppWidgetUpdater {
override suspend fun updateAll() {
LocalStatsWidget().updateAll(context)
NodeTrackerWidget().updateAll(context)
}
}
```

It also observes `stateProvider.state` itself (debounced 500 ms, ignoring timestamp-only changes) and re-renders whenever widget instances exist β€” Glance compositions are ephemeral, so re-renders must be driven externally. This keeps the service layer platform-agnostic while still enabling widget refresh on data changes.
It also drives re-renders itself, because Glance compositions are ephemeral:

- Local Stats: observes `stateProvider.state` (debounced 500 ms, ignoring timestamp-only changes) while instances exist.
- Node Tracker: samples `trackerDataSource.inputs` once a second, maps it to each instance's rendered state, and updates only when one of those changes. Sampling rather than debouncing keeps a busy mesh from starving the update.

This keeps the service layer platform-agnostic while still enabling widget refresh on data changes.

## Dependency Graph

### Key Dependencies

```text
feature:widget (Android only)
β”œβ”€β”€ core:common, core:model, core:resources, core:repository
β”œβ”€β”€ core:common, core:model, core:navigation, core:resources, core:repository, core:ui
β”œβ”€β”€ androidx.glance.appwidget, glance.material3, glance.preview, glance.appwidget.preview
β”œβ”€β”€ androidx.activity.compose, compose.multiplatform.material3 (NodeTrackerConfigActivity)
└── compose.multiplatform.ui (LocalConfiguration, LocalDensity)
```

Expand All @@ -134,8 +164,10 @@ graph TB
:feature:widget[widget]:::android-feature
:feature:widget -.-> :core:common
:feature:widget -.-> :core:model
:feature:widget -.-> :core:navigation
:feature:widget -.-> :core:resources
:feature:widget -.-> :core:repository
:feature:widget -.-> :core:ui

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;
Expand Down
6 changes: 6 additions & 0 deletions feature/widget/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,16 @@ android {
dependencies {
implementation(projects.core.common)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.resources)
implementation(projects.core.repository)
implementation(projects.core.ui)

implementation(libs.compose.multiplatform.ui) // LocalConfiguration, LocalDensity
implementation(libs.compose.multiplatform.foundation)
implementation(libs.compose.multiplatform.material3)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore.preferences) // Glance per-instance state; Glance ships it runtime-only
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.androidx.glance.preview)
Expand Down
Loading