diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 4015a3c5fff..ddb33ec1ce3 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1983,6 +1983,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 1fa0ade959f..c1ccd1d6410 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -354,6 +354,29 @@ 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 55fe654604f..7cca0b23fde 100644 --- a/docs/en/user/widget.md +++ b/docs/en/user/widget.md @@ -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 @@ -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 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 c704f52756e..378fe5753a7 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 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` 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 @@ -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/` 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: @@ -103,19 +125,26 @@ 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 @@ -123,8 +152,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) ``` @@ -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; 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..dad8002e482 --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt @@ -0,0 +1,220 @@ +/* + * 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.produceState +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.flow.first +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 +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 +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 connectionStateProvider: ConnectionStateProvider 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) { + // 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?>(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, + ourNodeNum = ourNodeNum, + connected = connectionState is ConnectionState.Connected, + 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?, connected: Boolean, onPick: (Node) -> Unit) { + var query by rememberSaveable { mutableStateOf("") } + 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 -> + 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) + } + }, + ) + 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(), + ) + } + } + } +} + +@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..ae132764561 --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidget.kt @@ -0,0 +1,326 @@ +/* + * 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.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 +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.appwidget.state.updateAppWidgetState +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 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 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) + } + } + + 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..c6f7f77cdfa --- /dev/null +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt @@ -0,0 +1,198 @@ +/* + * 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.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 +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 is missing from a populated 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(), + lastKnown: NodeTrackerWidgetState.Tracking? = null, +): NodeTrackerWidgetState { + 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 = + 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, + ) +} + +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, + 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 = 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/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..2ecc48a4b10 --- /dev/null +++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetStateTest.kt @@ -0,0 +1,171 @@ +/* + * 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 androidx.datastore.preferences.core.mutablePreferencesOf +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) + } + + @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 + const val HEARD = 1_700_000_000 + } +}