From 1658ae6e3ed4e1d64d24c37ebb56039cba87763e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:58:55 -0500 Subject: [PATCH 1/3] feat(widget): add a Node Tracker home-screen widget Follows one node the user picks: battery, signal, last heard, hops away and distance from our radio, matching the Node Tracker widget the Apple app ships. A configuration activity picks the node per instance, and a node that drops out of the node DB asks to be re-chosen. --- .skills/compose-ui/strings-index.txt | 2 + androidApp/src/main/AndroidManifest.xml | 26 +- .../meshtastic/app/BundledSqliteGuardTest.kt | 1 + .../composeResources/values/strings.xml | 4 +- docs/en/user/widget.md | 48 ++- .../feature/docs/data/DocBundleLoader.kt | 2 +- feature/widget/README.md | 46 ++- feature/widget/build.gradle.kts | 6 + .../feature/widget/AndroidAppWidgetUpdater.kt | 45 ++- .../feature/widget/LocalStatsWidget.kt | 27 +- .../feature/widget/NodeTrackerCandidates.kt | 39 +++ .../widget/NodeTrackerConfigActivity.kt | 192 +++++++++++ .../feature/widget/NodeTrackerWidget.kt | 311 ++++++++++++++++++ .../widget/NodeTrackerWidgetReceiver.kt | 24 ++ .../feature/widget/NodeTrackerWidgetState.kt | 122 +++++++ .../feature/widget/WidgetNodeChip.kt | 70 ++++ .../res/layout/widget_composition_error.xml | 2 +- .../layout/widget_node_tracker_preview.xml | 43 +++ .../widget/src/main/res/values/strings.xml | 2 + .../main/res/xml/widget_node_tracker_info.xml | 32 ++ .../widget/NodeTrackerCandidatesTest.kt | 62 ++++ .../feature/widget/NodeTrackerTestNodes.kt | 51 +++ .../widget/NodeTrackerWidgetContentTest.kt | 123 +++++++ .../widget/NodeTrackerWidgetStateTest.kt | 121 +++++++ 24 files changed, 1346 insertions(+), 55 deletions(-) create mode 100644 feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt create mode 100644 feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt create mode 100644 feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt create mode 100644 feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetReceiver.kt create mode 100644 feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt create mode 100644 feature/widget/src/main/kotlin/org/meshtastic/feature/widget/WidgetNodeChip.kt create mode 100644 feature/widget/src/main/res/layout/widget_node_tracker_preview.xml create mode 100644 feature/widget/src/main/res/xml/widget_node_tracker_info.xml create mode 100644 feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidatesTest.kt create mode 100644 feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerTestNodes.kt create mode 100644 feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetContentTest.kt create mode 100644 feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 7b50476b54e..0c11a6aae12 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -2040,6 +2040,8 @@ website weight what_is_meshtastic what_is_meshtastic_description +widget_node_tracker_choose +widget_node_tracker_not_found ### WIFI ### wifi_config wifi_devices diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index edeba531d34..40aa8ec4bd3 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -366,6 +366,28 @@ android:resource="@xml/widget_local_stats_info" /> + + + + + + + + + + + + + + Wi-Fi Options Wi-Fi Provisioning for mPWRD-OS diff --git a/docs/en/user/widget.md b/docs/en/user/widget.md index e20fc5a2dc9..8fe062efd15 100644 --- a/docs/en/user/widget.md +++ b/docs/en/user/widget.md @@ -3,20 +3,26 @@ title: Home Screen Widget parent: User Guide nav_order: 20 last_updated: 2026-10-21 -description: Add the Meshtastic home screen widget to glance at your connected node's local stats without opening the app. +description: Add Meshtastic home screen widgets to glance at your connected node'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 node at a glance, without opening the app. +On Android, Meshtastic provides two home screen **widgets**: -## What It Shows +- **Meshtastic** shows live local statistics from your connected node. +- **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 connected node. -The widget displays the **connected node'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 node's** current local stats: - A **node chip** across the top, carrying the node's short name in its own colors - **Battery** — the node's battery level, or *Powered* when running on external power @@ -34,17 +40,39 @@ Tap the widget to open the app, or use its refresh control to request fresh stat > ℹ️ **Note:** The values reflect the connected node. If the node 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 connected node last heard it, 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 connected node. It appears only while you are connected and both nodes 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. + +If the node you picked is removed from your node list, 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 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 in the desktop app. +> ℹ️ **Note:** The widgets are Android-only. They are not available in the desktop app. ## Related Topics - [Node Metrics](node-metrics) — the full Signal Quality and Local Stats history inside the app -- [Connections](connections) — connect to a node so the widget has stats to show +- [Nodes](nodes) — favorites, last heard, and node details +- [Connections](connections) — connect to a node so the widgets have data to show - [Local Mesh Discovery](discovery) — channel and airtime utilization across the mesh diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt index 30c6af270cc..8c7a78a4b76 100644 --- a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt @@ -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", ), diff --git a/feature/widget/README.md b/feature/widget/README.md index c98f7ac3524..19a6acb9ec6 100644 --- a/feature/widget/README.md +++ b/feature/widget/README.md @@ -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 node. +- `NodeTrackerWidget` follows one node the user picks: battery, signal, last heard, hops away and distance from our node. **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` 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 - Fulfill the `AppWidgetUpdater` interface from `:core:repository` so the mesh service can trigger widget refreshes without depending on Android widget APIs ## Source Structure @@ -20,6 +23,12 @@ 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 ← ActionCallback (refresh button) ├── WidgetResourceEnvironment.kt ← bridges Compose resources into Glance @@ -27,7 +36,20 @@ src/main/kotlin/org/meshtastic/feature/widget/ └── FeatureWidgetModule.kt ``` -## Widget Content +## Node Tracker + +Each instance stores its node number under `TRACKED_NODE_NUM_KEY` in its Glance Preferences state. `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/` deep link. The picker lists favorites first, then the most recently heard, leaving out our own node 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: @@ -110,19 +132,26 @@ Glance doesn't provide the Compose UI locals that Compose Multiplatform's `strin 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 @@ -130,8 +159,9 @@ It also observes `stateProvider.state` itself (debounced 500 ms, ignoring timest ```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), compose.multiplatform.resources └── kermit ``` @@ -146,8 +176,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; diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index e7543ae7ffe..ee86b711688 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -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) diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt index 42c3acd03fa..c3223f20a5b 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt @@ -17,8 +17,12 @@ package org.meshtastic.feature.widget import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.updateAll +import androidx.glance.state.PreferencesGlanceStateDefinition import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -27,15 +31,21 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.repository.AppWidgetUpdater private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L +private const val TRACKER_UPDATE_SAMPLE_MS = 1_000L @Single -class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) : - AppWidgetUpdater { +class AndroidAppWidgetUpdater( + private val context: Context, + stateProvider: LocalStatsWidgetStateProvider, + trackerDataSource: NodeTrackerWidgetDataSource, +) : AppWidgetUpdater { @Suppress("InjectDispatcher") // feature:widget does not depend on core:di, where CoroutineDispatchers lives private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -48,21 +58,42 @@ class AndroidAppWidgetUpdater(private val context: Context, stateProvider: Local stateProvider.state .debounce(WIDGET_UPDATE_DEBOUNCE_MS) .distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) } - .collect { if (hasWidgetInstances()) updateAll() } + .collect { if (hasInstances(LocalStatsWidget::class.java)) update(LocalStatsWidget()) } + } + + // Sampled rather than debounced: on a busy mesh the node DB changes faster than any debounce window, which + // would starve the trackers. Only the rendered state of the tracked nodes counts as a change. + @OptIn(FlowPreview::class) + scope.launch { + trackerDataSource.inputs + .sample(TRACKER_UPDATE_SAMPLE_MS) + .map { inputs -> trackedNodeNums().map { nodeTrackerState(it, inputs) } } + .distinctUntilChanged() + .collect { states -> if (states.isNotEmpty()) update(NodeTrackerWidget()) } } } - private suspend fun hasWidgetInstances(): Boolean = - GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty() + private suspend fun hasInstances(widget: Class): Boolean = + GlanceAppWidgetManager(context).getGlanceIds(widget).isNotEmpty() + + private suspend fun trackedNodeNums(): List = + GlanceAppWidgetManager(context).getGlanceIds(NodeTrackerWidget::class.java).map { id -> + getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[TRACKED_NODE_NUM_KEY] + } override suspend fun updateAll() { + update(LocalStatsWidget()) + update(NodeTrackerWidget()) + } + + private suspend fun update(widget: GlanceAppWidget) { @Suppress("TooGenericExceptionCaught") try { - LocalStatsWidget().updateAll(context) + widget.updateAll(context) } catch (e: CancellationException) { throw e } catch (e: Exception) { - Logger.e(e) { "Failed to update widgets" } + Logger.e(e) { "Failed to update ${widget::class.simpleName}" } } } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index a3a60d5885a..8cc6cdc03c8 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.feature.widget -import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent @@ -24,7 +23,6 @@ import android.widget.RemoteViews import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -47,7 +45,6 @@ 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 @@ -61,7 +58,6 @@ 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 co.touchlab.kermit.Logger import org.jetbrains.compose.resources.stringResource import org.koin.core.component.KoinComponent @@ -196,7 +192,7 @@ class LocalStatsWidget : // 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) } + state.nodeColors?.let { colors -> WidgetNodeChip(shortName = name, colors = colors) } } Spacer(GlanceModifier.width(8.dp)) if (state.hasBattery) { @@ -403,27 +399,6 @@ class LocalStatsWidget : } } - @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, diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt new file mode 100644 index 00000000000..76c129c7fae --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt @@ -0,0 +1,39 @@ +/* + * 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 org.meshtastic.feature.widget + +import org.meshtastic.core.model.Node + +/** + * The nodes the tracker picker offers: favorites first, then most recently heard, filtered by [query] against the long + * name, short name and node id. Our own radio is left out, since Local Stats already covers it, and so are ignored + * nodes. + */ +internal fun trackerCandidates(nodes: Collection, query: String, ourNodeNum: Int?): List { + val needle = query.trim() + return nodes + .asSequence() + .filter { !it.isIgnored && it.num != ourNodeNum } + .filter { node -> + needle.isEmpty() || + node.user.long_name.contains(needle, ignoreCase = true) || + node.user.short_name.contains(needle, ignoreCase = true) || + node.user.id.contains(needle, ignoreCase = true) + } + .sortedWith(compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }.thenBy { it.num }) + .toList() +} diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt new file mode 100644 index 00000000000..e757f2219bd --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt @@ -0,0 +1,192 @@ +/* + * 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 org.meshtastic.feature.widget + +import android.app.UiModeManager +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.lifecycle.lifecycleScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.doc_no_results +import org.meshtastic.core.resources.node_filter_placeholder +import org.meshtastic.core.resources.nodes_empty_searching_hint +import org.meshtastic.core.resources.nodes_empty_searching_title +import org.meshtastic.core.resources.widget_node_tracker_choose +import org.meshtastic.core.ui.component.EmptyState +import org.meshtastic.core.ui.component.MeshtasticSearchBar +import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Nodes +import org.meshtastic.core.ui.icon.Search +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.theme.MODE_DYNAMIC +import org.meshtastic.core.ui.util.formatAgo + +/** + * Picks the node a [NodeTrackerWidget] instance tracks. The launcher opens it when the widget is placed or + * reconfigured, and the widget opens it when its node has gone. + */ +class NodeTrackerConfigActivity : + ComponentActivity(), + KoinComponent { + + private val nodeRepository: NodeRepository by inject() + private val uiPrefs: UiPrefs by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Backing out must cancel placement, so the result is CANCELED until a node is picked. + setResult(RESULT_CANCELED) + + val appWidgetId = + intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + // Exported for the launcher, so only ids bound to our own tracker provider are honored. + val provider = AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider + if (provider != ComponentName(this, NodeTrackerWidgetReceiver::class.java)) { + finish() + return + } + + enableEdgeToEdge() + setContent { + val theme by uiPrefs.theme.collectAsState() + // UiPrefs.theme holds AppCompatDelegate night modes, whose values UiModeManager shares. + val dark = + when (theme) { + UiModeManager.MODE_NIGHT_YES -> true + UiModeManager.MODE_NIGHT_NO -> false + else -> isSystemInDarkTheme() + } + AppTheme(dynamicColor = theme == MODE_DYNAMIC, darkTheme = dark) { + val nodes by nodeRepository.nodeDBbyNum.collectAsState() + val ourNodeNum = nodeRepository.myNodeInfo.collectAsState().value?.myNodeNum + NodeTrackerPicker( + nodes = nodes.values, + ourNodeNum = ourNodeNum, + onPick = { node -> track(appWidgetId, node.num) }, + ) + } + } + } + + private fun track(appWidgetId: Int, nodeNum: Int) { + lifecycleScope.launch { + try { + val glanceId = GlanceAppWidgetManager(this@NodeTrackerConfigActivity).getGlanceIdBy(appWidgetId) + updateAppWidgetState(this@NodeTrackerConfigActivity, glanceId) { it[TRACKED_NODE_NUM_KEY] = nodeNum } + NodeTrackerWidget().update(this@NodeTrackerConfigActivity, glanceId) + setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)) + } catch (e: IllegalArgumentException) { + Logger.w(e) { "Node Tracker widget $appWidgetId is not a Glance widget; leaving it unconfigured" } + } + finish() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NodeTrackerPicker(nodes: Collection, ourNodeNum: Int?, onPick: (Node) -> Unit) { + var query by rememberSaveable { mutableStateOf("") } + val all = remember(nodes, ourNodeNum) { trackerCandidates(nodes, "", ourNodeNum) } + val matches = remember(nodes, ourNodeNum, query) { trackerCandidates(nodes, query, ourNodeNum) } + Scaffold( + topBar = { TopAppBar(title = { Text(stringResource(Res.string.widget_node_tracker_choose)) }) }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + MeshtasticSearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(Res.string.node_filter_placeholder), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + expandedContent = { + if (matches.isEmpty()) { + EmptyState(icon = MeshtasticIcons.Search, title = stringResource(Res.string.doc_no_results)) + } else { + CandidateList(matches, onPick) + } + }, + ) + if (all.isEmpty()) { + EmptyState( + icon = MeshtasticIcons.Nodes, + title = stringResource(Res.string.nodes_empty_searching_title), + supportingText = stringResource(Res.string.nodes_empty_searching_hint), + modifier = Modifier.fillMaxSize(), + ) + } else { + CandidateList(all, onPick) + } + } + } +} + +@Composable +private fun ColumnScope.CandidateList(nodes: List, onPick: (Node) -> Unit) { + LazyColumn(modifier = Modifier.weight(1f)) { + items(nodes, key = { it.num }) { node -> + ListItem( + modifier = Modifier.clickable { onPick(node) }, + leadingContent = { NodeChip(node = node) }, + headlineContent = { + Text(node.user.long_name.ifEmpty { node.user.id }, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { Text(formatAgo(node.lastHeard)) }, + ) + } + } +} diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt new file mode 100644 index 00000000000..1beda4397d0 --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt @@ -0,0 +1,311 @@ +/* + * 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 org.meshtastic.feature.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.RemoteViews +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +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.Action +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +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.semantics.semantics +import androidx.glance.semantics.testTag +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.filter +import org.jetbrains.compose.resources.stringResource +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.battery +import org.meshtastic.core.resources.distance +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.hops_away +import org.meshtastic.core.resources.node_sort_last_heard +import org.meshtastic.core.resources.powered +import org.meshtastic.core.resources.rssi +import org.meshtastic.core.resources.snr +import org.meshtastic.core.resources.traceroute_direct +import org.meshtastic.core.resources.unknown +import org.meshtastic.core.resources.unknown_error +import org.meshtastic.core.resources.widget_node_tracker_not_found + +/** Tracks one node the user picked: battery, signal, last heard, hops away and distance from our radio. */ +class NodeTrackerWidget : + GlanceAppWidget(), + KoinComponent { + + override val sizeMode: SizeMode = SizeMode.Responsive(RESPONSIVE_SIZES) + override val previewSizeMode: androidx.glance.appwidget.PreviewSizeMode = SizeMode.Responsive(RESPONSIVE_SIZES) + + private val dataSource: NodeTrackerWidgetDataSource by inject() + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val initialInputs = dataSource.currentInputs() + provideContent { + val nodeNum = currentState()[TRACKED_NODE_NUM_KEY] + // Skips the repository's empty initial map so a cold start does not flash "not found". + val inputs by remember { dataSource.inputs.filter { it.nodes.isNotEmpty() } }.collectAsState(initialInputs) + WidgetContent(nodeTrackerState(nodeNum, inputs), appWidgetId) + } + } + + override suspend fun providePreview(context: Context, widgetCategory: Int) { + provideContent { WidgetContent(createMockTrackerState(), AppWidgetManager.INVALID_APPWIDGET_ID) } + } + + /** Same last-resort error UI as [LocalStatsWidget.onCompositionError]. */ + override fun onCompositionError(context: Context, glanceId: GlanceId, appWidgetId: Int, throwable: Throwable) { + Logger.e(throwable) { "NodeTrackerWidget composition failed; rendering error UI" } + val errorViews = + RemoteViews(context.packageName, R.layout.widget_composition_error).apply { + setTextViewText(R.id.widget_composition_error_text, getString(Res.string.unknown_error)) + } + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, errorViews) + } + + @Composable + internal fun WidgetContent(state: NodeTrackerWidgetState, appWidgetId: Int) { + val context = LocalContext.current + val onClick = + when (state) { + is NodeTrackerWidgetState.Tracking -> openNodeDetail(context, state.nodeNum) + NodeTrackerWidgetState.NodeNotFound -> chooseNode(appWidgetId) + } + // Mandatory: without this bridge every stringResource call below throws. See WidgetResourceEnvironment. + WidgetResourceEnvironment { + GlanceTheme { + Scaffold(modifier = GlanceModifier.fillMaxSize().clickable(onClick)) { + when (state) { + NodeTrackerWidgetState.NodeNotFound -> NotFound() + is NodeTrackerWidgetState.Tracking -> TrackerContent(state) + } + } + } + } + } + + companion object { + internal const val STALE_TEST_TAG = "node_tracker_stale" + + private val SMALL_SQUARE = DpSize(110.dp, 110.dp) + private val HORIZONTAL_RECTANGLE = DpSize(250.dp, 110.dp) + private val RESPONSIVE_SIZES = setOf(SMALL_SQUARE, HORIZONTAL_RECTANGLE) + + private fun openNodeDetail(context: Context, nodeNum: Int): Action = actionStartActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("$DEEP_LINK_BASE_URI/nodes/$nodeNum")) + .setClassName(context.packageName, MAIN_ACTIVITY_CLASS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + + private fun chooseNode(appWidgetId: Int): Action = actionStartActivity( + NodeTrackerConfigActivity::class.java, + actionParametersOf(ActionParameters.Key(AppWidgetManager.EXTRA_APPWIDGET_ID) to appWidgetId), + ) + + private const val MAIN_ACTIVITY_CLASS = "org.meshtastic.app.MainActivity" + } +} + +private const val MAX_BATTERY_PERCENT = 100 +private val WIDE_MIN_WIDTH = 200.dp + +@Composable +private fun TrackerContent(state: NodeTrackerWidgetState.Tracking) { + if (LocalSize.current.width >= WIDE_MIN_WIDTH) Wide(state) else Small(state) +} + +@Composable +private fun Small(state: NodeTrackerWidgetState.Tracking) { + Column(modifier = GlanceModifier.fillMaxSize().padding(vertical = 8.dp).staleTag(state)) { + Identity(state) + Spacer(GlanceModifier.defaultWeight()) + BatteryLine(state) + LastHeardLine(state) + } +} + +@Composable +private fun Wide(state: NodeTrackerWidgetState.Tracking) { + Row(modifier = GlanceModifier.fillMaxSize().padding(vertical = 8.dp).staleTag(state)) { + Column(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) { + Identity(state) + Spacer(GlanceModifier.defaultWeight()) + LastHeardLine(state) + } + Spacer(GlanceModifier.width(12.dp)) + Column(modifier = GlanceModifier.defaultWeight()) { + BatteryLine(state) + state.snr?.let { StatLine(stringResource(Res.string.snr), MetricFormatter.snr(it), state.isStale) } + state.rssi?.let { StatLine(stringResource(Res.string.rssi), MetricFormatter.rssi(it), state.isStale) } + state.hopsAway?.let { hops -> + val value = if (hops == 0) stringResource(Res.string.traceroute_direct) else hops.toString() + StatLine(stringResource(Res.string.hops_away), value, state.isStale) + } + state.distance?.let { StatLine(stringResource(Res.string.distance), it, state.isStale) } + } + } +} + +@Composable +private fun Identity(state: NodeTrackerWidgetState.Tracking) { + Column { + WidgetNodeChip(shortName = state.shortName, colors = state.colors, dimmed = state.isStale) + Spacer(GlanceModifier.height(4.dp)) + Text( + text = state.longName, + maxLines = 1, + style = TextStyle(color = valueColor(state.isStale), fontSize = 13.sp, fontWeight = FontWeight.Bold), + ) + } +} + +@Composable +private fun BatteryLine(state: NodeTrackerWidgetState.Tracking) { + val level = state.batteryLevel ?: return + val value = if (level > MAX_BATTERY_PERCENT) stringResource(Res.string.powered) else MetricFormatter.percent(level) + StatLine(stringResource(Res.string.battery), value, state.isStale) +} + +@Composable +private fun LastHeardLine(state: NodeTrackerWidgetState.Tracking) { + // An absolute time, because a relative "5 min ago" would freeze between widget updates and go on reading fresh. + val value = + if (state.lastHeard > 0) { + DateFormatter.formatShortDate(state.lastHeard.toLong() * TimeConstants.MS_PER_SEC) + } else { + stringResource(Res.string.unknown) + } + StatLine(stringResource(Res.string.node_sort_last_heard), value, state.isStale) +} + +@Composable +private fun StatLine(label: String, value: String, isStale: Boolean) { + Row( + modifier = GlanceModifier.fillMaxWidth().padding(vertical = 1.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp), + modifier = GlanceModifier.defaultWeight(), + ) + Text( + text = value, + maxLines = 1, + style = TextStyle(color = valueColor(isStale), fontSize = 11.sp, fontWeight = FontWeight.Medium), + ) + } +} + +@Composable +private fun NotFound() { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + provider = ImageProvider(R.drawable.widget_app_icon), + contentDescription = null, + modifier = GlanceModifier.size(32.dp), + ) + Spacer(GlanceModifier.height(6.dp)) + Text( + text = stringResource(Res.string.widget_node_tracker_not_found), + style = + TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + ) + } +} + +@Composable +@ReadOnlyComposable +private fun valueColor(isStale: Boolean): ColorProvider = + if (isStale) GlanceTheme.colors.outline else GlanceTheme.colors.onSurface + +private fun GlanceModifier.staleTag(state: NodeTrackerWidgetState.Tracking): GlanceModifier = + if (state.isStale) semantics { testTag = NodeTrackerWidget.STALE_TEST_TAG } else this + +@Suppress("MagicNumber") +internal fun createMockTrackerState() = NodeTrackerWidgetState.Tracking( + nodeNum = 0x43B2A1, + shortName = "BCS1", + longName = "Basecamp Solar", + colors = 0xFFFFFFFF.toInt() to 0xFF2C6E49.toInt(), + batteryLevel = 76, + snr = 9.5f, + rssi = -85, + hopsAway = 0, + lastHeard = (nowSeconds - 480).toInt(), + distance = "1.2 km", + isStale = false, +) diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetReceiver.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetReceiver.kt new file mode 100644 index 00000000000..a6a77ebd5c6 --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetReceiver.kt @@ -0,0 +1,24 @@ +/* + * 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 org.meshtastic.feature.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class NodeTrackerWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = NodeTrackerWidget() +} diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt new file mode 100644 index 00000000000..0d80ce8439d --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt @@ -0,0 +1,122 @@ +/* + * 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 org.meshtastic.feature.widget + +import androidx.datastore.preferences.core.intPreferencesKey +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.Single +import org.meshtastic.core.common.di.ApplicationCoroutineScope +import org.meshtastic.core.common.util.LocaleUnitsProvider +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.util.onlineTimeThreshold +import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.repository.ConnectionStateProvider +import org.meshtastic.core.repository.NodeRepository + +/** What one Node Tracker instance renders. */ +sealed interface NodeTrackerWidgetState { + /** No node chosen yet, or the chosen one has left the node DB. Tapping reopens the picker. */ + data object NodeNotFound : NodeTrackerWidgetState + + data class Tracking( + val nodeNum: Int, + val shortName: String, + val longName: String, + val colors: Pair, + val batteryLevel: Int?, + val snr: Float?, + val rssi: Int?, + val hopsAway: Int?, + val lastHeard: Int, + val distance: String?, + val isStale: Boolean, + ) : NodeTrackerWidgetState +} + +/** Everything a tracker needs besides its own node number, shared by every instance. */ +data class NodeTrackerInputs( + val nodes: Map = emptyMap(), + val ourNode: Node? = null, + val connectionState: ConnectionState = ConnectionState.Disconnected, + val units: MeasurementSystem = MeasurementSystem.METRIC, +) + +/** Per-instance Glance state key holding the tracked node number. */ +internal val TRACKED_NODE_NUM_KEY = intPreferencesKey("tracked_node_num") + +internal fun nodeTrackerState( + nodeNum: Int?, + inputs: NodeTrackerInputs, + onlineThreshold: Int = onlineTimeThreshold(), +): NodeTrackerWidgetState { + val node = nodeNum?.let { inputs.nodes[it] } ?: return NodeTrackerWidgetState.NodeNotFound + val direct = node.hopsAway == 0 && !node.viaMqtt + // A disconnected radio's last position would read as live, so distance is shown only while connected. + val distance = + inputs.ourNode + ?.takeIf { inputs.connectionState is ConnectionState.Connected && it.num != node.num } + ?.let { node.distance(it) } + ?.takeIf { it > 0 } + ?.toDistanceString(inputs.units) + return NodeTrackerWidgetState.Tracking( + nodeNum = node.num, + shortName = node.user.short_name.ifEmpty { "???" }, + longName = node.user.long_name.ifEmpty { NodeAddress.numToDefaultId(node.num) }, + colors = node.colors, + batteryLevel = node.batteryLevel?.takeIf { it > 0 }, + snr = if (direct) node.snrOrNull else null, + rssi = if (direct) node.rssiOrNull else null, + hopsAway = node.hopsAway.takeIf { it >= 0 }, + lastHeard = node.lastHeard, + distance = distance, + isStale = node.lastHeard <= onlineThreshold, + ) +} + +@Single +class NodeTrackerWidgetDataSource( + private val nodeRepository: NodeRepository, + connectionStateProvider: ConnectionStateProvider, + localeUnitsProvider: LocaleUnitsProvider, + scope: ApplicationCoroutineScope, +) { + val inputs: StateFlow = + combine( + nodeRepository.nodeDBbyNum, + nodeRepository.ourNodeInfo, + connectionStateProvider.connectionState, + localeUnitsProvider.measurementSystem, + ) { nodes, ourNode, connectionState, units -> + NodeTrackerInputs(nodes, ourNode, connectionState, units) + } + .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = NodeTrackerInputs()) + + /** + * The current inputs, reading the database directly while the repository's flow still holds its empty initial value + * after a cold process start; otherwise a widget rendered then would claim its node was not found. + */ + suspend fun currentInputs(): NodeTrackerInputs { + val current = inputs.value + return if (current.nodes.isEmpty()) current.copy(nodes = nodeRepository.getNodeDbSnapshot()) else current + } +} diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/WidgetNodeChip.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/WidgetNodeChip.kt new file mode 100644 index 00000000000..10c99905df6 --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/WidgetNodeChip.kt @@ -0,0 +1,70 @@ +/* + * 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 org.meshtastic.feature.widget + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.padding +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 + +private const val DIMMED_ALPHA = 0.55f + +/** The app's node chip, short name on the node's own colors, for a Glance tree. */ +@SuppressLint("RestrictedApi") +@Composable +internal fun WidgetNodeChip( + shortName: String, + colors: Pair, + modifier: GlanceModifier = GlanceModifier, + dimmed: Boolean = false, +) { + val (fg, bg) = colors + val alpha = if (dimmed) DIMMED_ALPHA else 1f + Row( + modifier = + modifier + .width(64.dp) + .background(Color(bg).copy(alpha = alpha)) + .cornerRadius(4.dp) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = shortName, + maxLines = 1, + style = + TextStyle( + color = ColorProvider(Color(fg).copy(alpha = alpha)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + ), + ) + } +} diff --git a/feature/widget/src/main/res/layout/widget_composition_error.xml b/feature/widget/src/main/res/layout/widget_composition_error.xml index 0134de984e6..3be17b7878f 100644 --- a/feature/widget/src/main/res/layout/widget_composition_error.xml +++ b/feature/widget/src/main/res/layout/widget_composition_error.xml @@ -17,7 +17,7 @@ --> + + + + + + + + + diff --git a/feature/widget/src/main/res/values/strings.xml b/feature/widget/src/main/res/values/strings.xml index 027dcc46ef7..659cb54364d 100644 --- a/feature/widget/src/main/res/values/strings.xml +++ b/feature/widget/src/main/res/values/strings.xml @@ -17,4 +17,6 @@ --> Meshtastic + + Node Tracker diff --git a/feature/widget/src/main/res/xml/widget_node_tracker_info.xml b/feature/widget/src/main/res/xml/widget_node_tracker_info.xml new file mode 100644 index 00000000000..a3451749ad7 --- /dev/null +++ b/feature/widget/src/main/res/xml/widget_node_tracker_info.xml @@ -0,0 +1,32 @@ + + + + diff --git a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidatesTest.kt b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidatesTest.kt new file mode 100644 index 00000000000..87d67440558 --- /dev/null +++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidatesTest.kt @@ -0,0 +1,62 @@ +/* + * 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 org.meshtastic.feature.widget + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NodeTrackerCandidatesTest { + + @Test + fun `favorites come first then the most recently heard`() { + val nodes = + listOf( + testNode(1, lastHeard = 100), + testNode(2, lastHeard = 300), + testNode(3, lastHeard = 50, isFavorite = true), + testNode(4, lastHeard = 200, isFavorite = true), + ) + + assertEquals(listOf(4, 3, 2, 1), trackerCandidates(nodes, "", ourNodeNum = null).map { it.num }) + } + + @Test + fun `our own radio and ignored nodes are left out`() { + val nodes = listOf(testNode(1), testNode(2, isIgnored = true), testNode(3)) + + assertEquals(listOf(3), trackerCandidates(nodes, "", ourNodeNum = 1).map { it.num }) + } + + @Test + fun `the query matches long name short name and id ignoring case`() { + val nodes = + listOf( + testNode(0x11, shortName = "BCS1", longName = "Basecamp Solar"), + testNode(0x22, shortName = "RPT", longName = "Ridge Repeater"), + testNode(0x33, shortName = "HOME", longName = "Kitchen"), + ) + + assertEquals(listOf(0x11), trackerCandidates(nodes, "basecamp", null).map { it.num }) + assertEquals(listOf(0x22), trackerCandidates(nodes, "rpt", null).map { it.num }) + assertEquals(listOf(0x33), trackerCandidates(nodes, " !00000033 ", null).map { it.num }) + } + + @Test + fun `a query with no match returns nothing`() { + assertEquals(emptyList(), trackerCandidates(listOf(testNode(1)), "zzz", null)) + } +} diff --git a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerTestNodes.kt b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerTestNodes.kt new file mode 100644 index 00000000000..3108ca40165 --- /dev/null +++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerTestNodes.kt @@ -0,0 +1,51 @@ +/* + * 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 org.meshtastic.feature.widget + +import org.meshtastic.core.model.Node +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.Position +import org.meshtastic.proto.User + +@Suppress("LongParameterList") +internal fun testNode( + num: Int, + shortName: String = "N$num", + longName: String = "Node $num", + lastHeard: Int = 0, + hopsAway: Int = 0, + isFavorite: Boolean = false, + isIgnored: Boolean = false, + battery: Int? = null, + latitudeI: Int? = null, + longitudeI: Int? = null, + snr: Float = Node.SNR_UNSET, + rssi: Int = Node.RSSI_UNSET, + viaMqtt: Boolean = false, +) = Node( + num = num, + user = User.Builder().id("!%08x".format(num)).short_name(shortName).long_name(longName).build(), + position = Position.Builder().latitude_i(latitudeI).longitude_i(longitudeI).build(), + deviceMetrics = DeviceMetrics.Builder().battery_level(battery).build(), + lastHeard = lastHeard, + hopsAway = hopsAway, + isFavorite = isFavorite, + isIgnored = isIgnored, + snr = snr, + rssi = rssi, + viaMqtt = viaMqtt, +) diff --git a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetContentTest.kt b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetContentTest.kt new file mode 100644 index 00000000000..39c6021ab8c --- /dev/null +++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetContentTest.kt @@ -0,0 +1,123 @@ +/* + * 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 org.meshtastic.feature.widget + +import android.app.Application +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.appwidget.testing.unit.runGlanceAppWidgetUnitTest +import androidx.glance.testing.unit.hasStartActivityClickAction +import androidx.glance.testing.unit.hasTestTag +import androidx.glance.testing.unit.hasText +import androidx.test.core.app.ApplicationProvider +import org.junit.runner.RunWith +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.traceroute_direct +import org.meshtastic.core.resources.widget_node_tracker_not_found +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Unit tests for [NodeTrackerWidget.WidgetContent] on Glance's own harness, which asserts on the composed tree rather + * than pixels. As in [LocalStatsWidgetContentTest], any string rendering at all proves the [WidgetResourceEnvironment] + * bridge still resolves Compose Multiplatform resources. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [34]) +class NodeTrackerWidgetContentTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + // Resolved at construction: getString() wraps runBlocking, which can deadlock inside runGlanceAppWidgetUnitTest. + private val notFoundLabel: String = getString(Res.string.widget_node_tracker_not_found) + private val directLabel: String = getString(Res.string.traceroute_direct) + + @Test + fun `wide layout renders the tracked node and its readings`() = runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) { + setAppWidgetSize(WIDE) + setContext(context) + + provideComposable { NodeTrackerWidget().WidgetContent(createMockTrackerState(), WIDGET_ID) } + + onNode(hasText("BCS1")).assertExists() + onNode(hasText("Basecamp Solar")).assertExists() + onNode(hasText("76%")).assertExists() + onNode(hasText(directLabel)).assertExists() + onNode(hasText("1.2 km")).assertExists() + onNode(hasTestTag(NodeTrackerWidget.STALE_TEST_TAG)).assertDoesNotExist() + } + + @Test + fun `small layout keeps identity and battery but drops the detail column`() = + runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) { + setAppWidgetSize(SMALL) + setContext(context) + + provideComposable { NodeTrackerWidget().WidgetContent(createMockTrackerState(), WIDGET_ID) } + + onNode(hasText("BCS1")).assertExists() + onNode(hasText("76%")).assertExists() + onNode(hasText("1.2 km")).assertDoesNotExist() + } + + @Test + fun `unknown node asks to choose one and opens the picker for this widget`() = + runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) { + setAppWidgetSize(WIDE) + setContext(context) + + provideComposable { NodeTrackerWidget().WidgetContent(NodeTrackerWidgetState.NodeNotFound, WIDGET_ID) } + + onNode(hasText(notFoundLabel)).assertExists() + onNode( + hasStartActivityClickAction( + actionParametersOf(ActionParameters.Key(AppWidgetManager.EXTRA_APPWIDGET_ID) to WIDGET_ID), + ), + ) + .assertExists() + } + + @Test + fun `stale node still renders its readings and is marked dimmed`() = + runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) { + setAppWidgetSize(WIDE) + setContext(context) + + provideComposable { + NodeTrackerWidget().WidgetContent(createMockTrackerState().copy(isStale = true), WIDGET_ID) + } + + onNode(hasText("BCS1")).assertExists() + onNode(hasTestTag(NodeTrackerWidget.STALE_TEST_TAG)).assertExists() + } + + private companion object { + const val WIDGET_ID = 7 + val SMALL = DpSize(110.dp, 110.dp) + val WIDE = DpSize(250.dp, 110.dp) + + /** Same budget as LocalStatsWidgetContentTest, for the same reason: Robolectric plus resource loading. */ + val TEST_TIMEOUT = 30.seconds + } +} diff --git a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt new file mode 100644 index 00000000000..22ebfe2ce3c --- /dev/null +++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt @@ -0,0 +1,121 @@ +/* + * 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 org.meshtastic.feature.widget + +import android.app.Application +import org.junit.runner.RunWith +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Robolectric because distance formatting goes through the platform's ICU unit formatter. */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [34]) +class NodeTrackerWidgetStateTest { + + private val ourNode = testNode(OUR_NUM, latitudeI = 476_000_000, longitudeI = -1_223_000_000) + private val tracked = + testNode( + TRACKED_NUM, + lastHeard = HEARD, + battery = 76, + snr = 9.5f, + rssi = -85, + latitudeI = 476_100_000, + longitudeI = -1_223_000_000, + ) + + private fun inputs(vararg extra: Pair, connected: Boolean = true) = NodeTrackerInputs( + nodes = mapOf(OUR_NUM to ourNode, TRACKED_NUM to tracked) + extra, + ourNode = ourNode, + connectionState = if (connected) ConnectionState.Connected else ConnectionState.Disconnected, + ) + + @Test + fun `no configured node or one missing from the node DB is not found`() { + assertEquals(NodeTrackerWidgetState.NodeNotFound, nodeTrackerState(null, inputs())) + assertEquals(NodeTrackerWidgetState.NodeNotFound, nodeTrackerState(0x999, inputs())) + } + + @Test + fun `a tracked node carries its readings`() { + val state = assertIs(nodeTrackerState(TRACKED_NUM, inputs(), HEARD - 1)) + + assertEquals("N$TRACKED_NUM", state.shortName) + assertEquals(76, state.batteryLevel) + assertEquals(9.5f, state.snr) + assertEquals(-85, state.rssi) + assertEquals(0, state.hopsAway) + assertFalse(state.isStale) + } + + @Test + fun `a node last heard before the online window is stale`() { + val state = assertIs(nodeTrackerState(TRACKED_NUM, inputs(), HEARD)) + + assertTrue(state.isStale) + } + + @Test + fun `distance shows only while our radio is connected`() { + val connected = assertIs(nodeTrackerState(TRACKED_NUM, inputs())) + val disconnected = + assertIs(nodeTrackerState(TRACKED_NUM, inputs(connected = false))) + + assertNotNull(connected.distance) + assertNull(disconnected.distance) + } + + @Test + fun `signal is shown only for a direct node heard over RF`() { + val relayed = testNode(0x31, hopsAway = 2, snr = 4f, rssi = -100) + val mqtt = testNode(0x32, hopsAway = 0, snr = 4f, rssi = -100, viaMqtt = true) + val nodes = inputs(0x31 to relayed, 0x32 to mqtt) + + val relayedState = assertIs(nodeTrackerState(0x31, nodes)) + val mqttState = assertIs(nodeTrackerState(0x32, nodes)) + + assertNull(relayedState.snr) + assertNull(relayedState.rssi) + assertEquals(2, relayedState.hopsAway) + assertNull(mqttState.snr) + } + + @Test + fun `missing battery and unknown hops are absent rather than zero`() { + val bare = testNode(0x41, hopsAway = -1) + val state = assertIs(nodeTrackerState(0x41, inputs(0x41 to bare))) + + assertNull(state.batteryLevel) + assertNull(state.hopsAway) + assertNull(state.distance) + } + + private companion object { + const val OUR_NUM = 0x10 + const val TRACKED_NUM = 0x20 + const val HEARD = 1_700_000_000 + } +} From 3fa6a4312dea37c1c54b1d4b0fae4768a9513f4c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:52:01 -0500 Subject: [PATCH 2/3] fix(widget): label the Node Tracker in the picker and hold the picker order The launcher labels a widget from its receiver, not the provider XML, so both widgets showed the app name. The picker now takes one snapshot of the node DB; a live list re-sorted by last heard under the finger and tracked the wrong node. --- androidApp/src/main/AndroidManifest.xml | 3 ++- .../meshtastic/feature/widget/NodeTrackerConfigActivity.kt | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 40aa8ec4bd3..82e08c9c5b7 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -368,7 +368,8 @@ + android:exported="false" + android:label="@string/widget_node_tracker_label"> diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt index e757f2219bd..db1c63d4169 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -52,6 +53,7 @@ import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.updateAppWidgetState import androidx.lifecycle.lifecycleScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.core.component.KoinComponent @@ -112,7 +114,9 @@ class NodeTrackerConfigActivity : else -> isSystemInDarkTheme() } AppTheme(dynamicColor = theme == MODE_DYNAMIC, darkTheme = dark) { - val nodes by nodeRepository.nodeDBbyNum.collectAsState() + // One snapshot: a live list re-sorts by last heard under the user's finger on a busy mesh. + val nodes by + produceState(emptyMap()) { value = nodeRepository.nodeDBbyNum.first { it.isNotEmpty() } } val ourNodeNum = nodeRepository.myNodeInfo.collectAsState().value?.myNodeNum NodeTrackerPicker( nodes = nodes.values, From 3fb7e7c4a5750fb47f3144b35ea2f5402a81e767 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:09:35 -0500 Subject: [PATCH 3/3] fix(widget): keep the tracked node's last reading when no node DB is open Disconnecting selects no device, which opens the empty default DB, so the Node Tracker said "Node not found" and its picker waited forever for nodes. The widget now keeps its last reading in Glance state and shows it dimmed while the node DB is empty; not found means missing from a populated DB. The picker reads the DB directly and shows the disconnected empty state. --- docs/en/user/widget.md | 4 +- feature/widget/README.md | 2 +- .../widget/NodeTrackerConfigActivity.kt | 54 ++++++++---- .../feature/widget/NodeTrackerWidget.kt | 25 ++++-- .../feature/widget/NodeTrackerWidgetState.kt | 88 +++++++++++++++++-- .../widget/NodeTrackerWidgetStateTest.kt | 50 +++++++++++ 6 files changed, 195 insertions(+), 28 deletions(-) diff --git a/docs/en/user/widget.md b/docs/en/user/widget.md index 8fe062efd15..7d0ddb67cf5 100644 --- a/docs/en/user/widget.md +++ b/docs/en/user/widget.md @@ -56,7 +56,9 @@ Widen the widget to add a second column: 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. -If the node you picked is removed from your node list, the widget says **Node not found**. Tap it to choose another node. +After you disconnect from your node in the app, the widget keeps the tracked node's last readings on screen, dimmed, until you reconnect. + +If your node list does not include the node you picked, because you removed it or connected to a different node, the widget says **Node not found**. Tap it to choose another node. ## Adding a Widget diff --git a/feature/widget/README.md b/feature/widget/README.md index 19a6acb9ec6..f59508ca670 100644 --- a/feature/widget/README.md +++ b/feature/widget/README.md @@ -38,7 +38,7 @@ src/main/kotlin/org/meshtastic/feature/widget/ ## Node Tracker -Each instance stores its node number under `TRACKED_NODE_NUM_KEY` in its Glance Preferences state. `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. +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 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 | |---|---| diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt index db1c63d4169..dad8002e482 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt @@ -58,12 +58,16 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.doc_no_results import org.meshtastic.core.resources.node_filter_placeholder +import org.meshtastic.core.resources.nodes_empty_disconnected_hint +import org.meshtastic.core.resources.nodes_empty_disconnected_title import org.meshtastic.core.resources.nodes_empty_searching_hint import org.meshtastic.core.resources.nodes_empty_searching_title import org.meshtastic.core.resources.widget_node_tracker_choose @@ -71,6 +75,7 @@ import org.meshtastic.core.ui.component.EmptyState import org.meshtastic.core.ui.component.MeshtasticSearchBar import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.NoDevice import org.meshtastic.core.ui.icon.Nodes import org.meshtastic.core.ui.icon.Search import org.meshtastic.core.ui.theme.AppTheme @@ -86,6 +91,7 @@ class NodeTrackerConfigActivity : KoinComponent { private val nodeRepository: NodeRepository by inject() + private val connectionStateProvider: ConnectionStateProvider by inject() private val uiPrefs: UiPrefs by inject() override fun onCreate(savedInstanceState: Bundle?) { @@ -114,13 +120,19 @@ class NodeTrackerConfigActivity : else -> isSystemInDarkTheme() } AppTheme(dynamicColor = theme == MODE_DYNAMIC, darkTheme = dark) { - // One snapshot: a live list re-sorts by last heard under the user's finger on a busy mesh. + // One snapshot: a live list re-sorts by last heard under the user's finger on a busy mesh. Read the DB + // directly, since the repository's flow opens on an empty map; an empty DB waits for its first nodes. val nodes by - produceState(emptyMap()) { value = nodeRepository.nodeDBbyNum.first { it.isNotEmpty() } } + produceState?>(null) { + value = nodeRepository.getNodeDbSnapshot() + if (value.isNullOrEmpty()) value = nodeRepository.nodeDBbyNum.first { it.isNotEmpty() } + } val ourNodeNum = nodeRepository.myNodeInfo.collectAsState().value?.myNodeNum + val connectionState by connectionStateProvider.connectionState.collectAsState() NodeTrackerPicker( - nodes = nodes.values, + nodes = nodes?.values, ourNodeNum = ourNodeNum, + connected = connectionState is ConnectionState.Connected, onPick = { node -> track(appWidgetId, node.num) }, ) } @@ -144,10 +156,10 @@ class NodeTrackerConfigActivity : @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun NodeTrackerPicker(nodes: Collection, ourNodeNum: Int?, onPick: (Node) -> Unit) { +internal fun NodeTrackerPicker(nodes: Collection?, ourNodeNum: Int?, connected: Boolean, onPick: (Node) -> Unit) { var query by rememberSaveable { mutableStateOf("") } - val all = remember(nodes, ourNodeNum) { trackerCandidates(nodes, "", ourNodeNum) } - val matches = remember(nodes, ourNodeNum, query) { trackerCandidates(nodes, query, ourNodeNum) } + val all = remember(nodes, ourNodeNum) { trackerCandidates(nodes.orEmpty(), "", ourNodeNum) } + val matches = remember(nodes, ourNodeNum, query) { trackerCandidates(nodes.orEmpty(), query, ourNodeNum) } Scaffold( topBar = { TopAppBar(title = { Text(stringResource(Res.string.widget_node_tracker_choose)) }) }, ) { padding -> @@ -165,15 +177,27 @@ internal fun NodeTrackerPicker(nodes: Collection, ourNodeNum: Int?, onPick } }, ) - if (all.isEmpty()) { - EmptyState( - icon = MeshtasticIcons.Nodes, - title = stringResource(Res.string.nodes_empty_searching_title), - supportingText = stringResource(Res.string.nodes_empty_searching_hint), - modifier = Modifier.fillMaxSize(), - ) - } else { - CandidateList(all, onPick) + when { + // Still reading the snapshot; an empty state here would flash before the list. + nodes == null -> Unit + + all.isNotEmpty() -> CandidateList(all, onPick) + + connected -> + EmptyState( + icon = MeshtasticIcons.Nodes, + title = stringResource(Res.string.nodes_empty_searching_title), + supportingText = stringResource(Res.string.nodes_empty_searching_hint), + modifier = Modifier.fillMaxSize(), + ) + + else -> + EmptyState( + icon = MeshtasticIcons.NoDevice, + title = stringResource(Res.string.nodes_empty_disconnected_title), + supportingText = stringResource(Res.string.nodes_empty_disconnected_hint), + modifier = Modifier.fillMaxSize(), + ) } } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt index 1beda4397d0..ae132764561 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt @@ -22,10 +22,13 @@ import android.content.Intent import android.net.Uri import android.widget.RemoteViews import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -48,6 +51,7 @@ import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.components.Scaffold import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.currentState import androidx.glance.layout.Alignment import androidx.glance.layout.Column @@ -68,7 +72,6 @@ import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.filter import org.jetbrains.compose.resources.stringResource import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -105,10 +108,22 @@ class NodeTrackerWidget : val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) val initialInputs = dataSource.currentInputs() provideContent { - val nodeNum = currentState()[TRACKED_NODE_NUM_KEY] - // Skips the repository's empty initial map so a cold start does not flash "not found". - val inputs by remember { dataSource.inputs.filter { it.nodes.isNotEmpty() } }.collectAsState(initialInputs) - WidgetContent(nodeTrackerState(nodeNum, inputs), appWidgetId) + val prefs = currentState() + val inputs by remember { dataSource.checkedInputs }.collectAsState(initialInputs) + var rendered by remember { mutableStateOf(null) } + val fromDb = inputs.nodes.isNotEmpty() + val state = + nodeTrackerState(prefs[TRACKED_NODE_NUM_KEY], inputs, lastKnown = rendered ?: prefs.lastKnownTracking()) + LaunchedEffect(state, fromDb) { + val reading = (state as? NodeTrackerWidgetState.Tracking)?.takeIf { fromDb }?.asLastKnown() + if (reading != null) { + rendered = reading + if (prefs.lastKnownTracking() != reading) { + updateAppWidgetState(context, id) { it.storeLastKnown(reading) } + } + } + } + WidgetContent(state, appWidgetId) } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt index 0d80ce8439d..c6f7f77cdfa 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt @@ -16,10 +16,16 @@ */ package org.meshtastic.feature.widget +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single import org.meshtastic.core.common.di.ApplicationCoroutineScope @@ -35,7 +41,7 @@ import org.meshtastic.core.repository.NodeRepository /** What one Node Tracker instance renders. */ sealed interface NodeTrackerWidgetState { - /** No node chosen yet, or the chosen one has left the node DB. Tapping reopens the picker. */ + /** No node chosen yet, or the chosen one is missing from a populated node DB. Tapping reopens the picker. */ data object NodeNotFound : NodeTrackerWidgetState data class Tracking( @@ -68,8 +74,24 @@ internal fun nodeTrackerState( nodeNum: Int?, inputs: NodeTrackerInputs, onlineThreshold: Int = onlineTimeThreshold(), + lastKnown: NodeTrackerWidgetState.Tracking? = null, ): NodeTrackerWidgetState { - val node = nodeNum?.let { inputs.nodes[it] } ?: return NodeTrackerWidgetState.NodeNotFound + val node = nodeNum?.let { inputs.nodes[it] } + return when { + node != null -> trackingOf(node, inputs, onlineThreshold) + + // An empty node DB means no radio's DB is open (disconnected, or no device selected), not that the node left. + nodeNum != null && inputs.nodes.isEmpty() -> + lastKnown?.takeIf { it.nodeNum == nodeNum }?.asLastKnown() ?: NodeTrackerWidgetState.NodeNotFound + + else -> NodeTrackerWidgetState.NodeNotFound + } +} + +/** How [this] reading renders once no node DB backs it: dimmed, and without a distance from a radio now gone. */ +internal fun NodeTrackerWidgetState.Tracking.asLastKnown() = copy(distance = null, isStale = true) + +private fun trackingOf(node: Node, inputs: NodeTrackerInputs, onlineThreshold: Int): NodeTrackerWidgetState.Tracking { val direct = node.hopsAway == 0 && !node.viaMqtt // A disconnected radio's last position would read as live, so distance is shown only while connected. val distance = @@ -93,6 +115,54 @@ internal fun nodeTrackerState( ) } +private val LAST_KNOWN_NUM_KEY = intPreferencesKey("last_known_num") +private val LAST_KNOWN_SHORT_NAME_KEY = stringPreferencesKey("last_known_short_name") +private val LAST_KNOWN_LONG_NAME_KEY = stringPreferencesKey("last_known_long_name") +private val LAST_KNOWN_FOREGROUND_KEY = intPreferencesKey("last_known_foreground") +private val LAST_KNOWN_BACKGROUND_KEY = intPreferencesKey("last_known_background") +private val LAST_KNOWN_BATTERY_KEY = intPreferencesKey("last_known_battery") +private val LAST_KNOWN_SNR_KEY = floatPreferencesKey("last_known_snr") +private val LAST_KNOWN_RSSI_KEY = intPreferencesKey("last_known_rssi") +private val LAST_KNOWN_HOPS_KEY = intPreferencesKey("last_known_hops") +private val LAST_KNOWN_LAST_HEARD_KEY = intPreferencesKey("last_known_last_heard") + +/** + * The last reading this instance rendered from a node DB, kept in its Glance state for when no DB is open. + * [storeLastKnown] writes every key in one atomic edit, so the node number stands for the whole set. + */ +internal fun Preferences.lastKnownTracking(): NodeTrackerWidgetState.Tracking? = this[LAST_KNOWN_NUM_KEY]?.let { num -> + NodeTrackerWidgetState.Tracking( + nodeNum = num, + shortName = this[LAST_KNOWN_SHORT_NAME_KEY].orEmpty(), + longName = this[LAST_KNOWN_LONG_NAME_KEY].orEmpty(), + colors = (this[LAST_KNOWN_FOREGROUND_KEY] ?: 0) to (this[LAST_KNOWN_BACKGROUND_KEY] ?: 0), + batteryLevel = this[LAST_KNOWN_BATTERY_KEY], + snr = this[LAST_KNOWN_SNR_KEY], + rssi = this[LAST_KNOWN_RSSI_KEY], + hopsAway = this[LAST_KNOWN_HOPS_KEY], + lastHeard = this[LAST_KNOWN_LAST_HEARD_KEY] ?: 0, + distance = null, + isStale = true, + ) +} + +internal fun MutablePreferences.storeLastKnown(state: NodeTrackerWidgetState.Tracking) { + this[LAST_KNOWN_NUM_KEY] = state.nodeNum + this[LAST_KNOWN_SHORT_NAME_KEY] = state.shortName + this[LAST_KNOWN_LONG_NAME_KEY] = state.longName + this[LAST_KNOWN_FOREGROUND_KEY] = state.colors.first + this[LAST_KNOWN_BACKGROUND_KEY] = state.colors.second + putOrRemove(LAST_KNOWN_BATTERY_KEY, state.batteryLevel) + putOrRemove(LAST_KNOWN_SNR_KEY, state.snr) + putOrRemove(LAST_KNOWN_RSSI_KEY, state.rssi) + putOrRemove(LAST_KNOWN_HOPS_KEY, state.hopsAway) + this[LAST_KNOWN_LAST_HEARD_KEY] = state.lastHeard +} + +private fun MutablePreferences.putOrRemove(key: Preferences.Key, value: T?) { + if (value == null) remove(key) else this[key] = value +} + @Single class NodeTrackerWidgetDataSource( private val nodeRepository: NodeRepository, @@ -115,8 +185,14 @@ class NodeTrackerWidgetDataSource( * The current inputs, reading the database directly while the repository's flow still holds its empty initial value * after a cold process start; otherwise a widget rendered then would claim its node was not found. */ - suspend fun currentInputs(): NodeTrackerInputs { - val current = inputs.value - return if (current.nodes.isEmpty()) current.copy(nodes = nodeRepository.getNodeDbSnapshot()) else current - } + suspend fun currentInputs(): NodeTrackerInputs = checkedAgainstDb(inputs.value) + + /** + * [inputs], with each empty node map checked against the database the same way, so an empty map is passed on only + * when the open DB really is empty. + */ + val checkedInputs: Flow = inputs.map { checkedAgainstDb(it) } + + private suspend fun checkedAgainstDb(current: NodeTrackerInputs): NodeTrackerInputs = + if (current.nodes.isEmpty()) current.copy(nodes = nodeRepository.getNodeDbSnapshot()) else current } diff --git a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt index 22ebfe2ce3c..2ecc48a4b10 100644 --- a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt +++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.widget import android.app.Application +import androidx.datastore.preferences.core.mutablePreferencesOf import org.junit.runner.RunWith import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node @@ -113,6 +114,55 @@ class NodeTrackerWidgetStateTest { assertNull(state.distance) } + @Test + fun `an empty node DB keeps the last reading on screen dimmed`() { + val live = assertIs(nodeTrackerState(TRACKED_NUM, inputs(), HEARD - 1)) + val noDb = NodeTrackerInputs(connectionState = ConnectionState.Disconnected) + + val state = + assertIs( + nodeTrackerState(TRACKED_NUM, noDb, HEARD - 1, lastKnown = live.asLastKnown()), + ) + + assertEquals(live.copy(distance = null, isStale = true), state) + assertTrue(state.isStale) + assertNull(state.distance) + } + + @Test + fun `an empty node DB without a reading for this node is not found`() { + val other = assertIs(nodeTrackerState(OUR_NUM, inputs())).asLastKnown() + val noDb = NodeTrackerInputs() + + assertEquals(NodeTrackerWidgetState.NodeNotFound, nodeTrackerState(TRACKED_NUM, noDb)) + assertEquals(NodeTrackerWidgetState.NodeNotFound, nodeTrackerState(TRACKED_NUM, noDb, lastKnown = other)) + } + + @Test + fun `a populated node DB without the node is not found despite a last reading`() { + val live = assertIs(nodeTrackerState(TRACKED_NUM, inputs())) + val otherRadio = NodeTrackerInputs(nodes = mapOf(OUR_NUM to ourNode), ourNode = ourNode) + + assertEquals( + NodeTrackerWidgetState.NodeNotFound, + nodeTrackerState(TRACKED_NUM, otherRadio, lastKnown = live.asLastKnown()), + ) + } + + @Test + fun `the last reading survives a round trip through Glance state`() { + val live = assertIs(nodeTrackerState(TRACKED_NUM, inputs(), HEARD - 1)) + val bareNode = testNode(0x41, hopsAway = -1) + val bare = assertIs(nodeTrackerState(0x41, inputs(0x41 to bareNode))) + val prefs = mutablePreferencesOf() + + assertNull(prefs.lastKnownTracking()) + prefs.storeLastKnown(live.asLastKnown()) + assertEquals(live.asLastKnown(), prefs.lastKnownTracking()) + prefs.storeLastKnown(bare.asLastKnown()) + assertEquals(bare.asLastKnown(), prefs.lastKnownTracking()) + } + private companion object { const val OUR_NUM = 0x10 const val TRACKED_NUM = 0x20