From 4cee191978e13157b68638367b7cfa15dbd5b4c1 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sun, 18 Oct 2026 08:21:57 -0500
Subject: [PATCH 1/2] feat(notifications): Live Update for mesh status or a
tracked node
A user-started Android 16 Live Update on the service notification, the
counterpart of the Apple Live Activity. Off by default; Off leaves the
service notification as it was.
- Mesh status: connection, nodes online, unread messages and the radio's
battery, with nodes online in the status bar chip.
- Track a node: its battery, signal, last heard, hops and distance, with
its battery (or time since heard) in the chip.
- The mode is stored in DataStore (`LiveUpdatePrefs`). A user disconnect,
a radio switch, or the tracked node being deleted or ignored turns it
off; a dropped link keeps it and shows the connection state.
- Connections gets a Live Update row and chooser (favorites first, then
most recently heard, searchable); node detail gets a Track in Live
Update switch. Both are Android 16+ only.
---
.skills/compose-ui/strings-index.txt | 23 ++
.../prefs/notification/LiveUpdatePrefsImpl.kt | 48 +++
.../prefs/notification/LiveUpdatePrefsTest.kt | 84 +++++
.../core/repository/LiveUpdatePrefs.kt | 59 ++++
.../composeResources/values/strings.xml | 23 ++
...NotificationManagerImplConversationTest.kt | 6 +
.../MeshNotificationManagerImplRoutingTest.kt | 6 +
.../MeshNotificationManagerImplTest.kt | 146 +++++++-
.../service/MeshNotificationManagerImpl.kt | 229 +++++++++++++
.../core/service/LiveUpdateGuard.kt | 86 +++++
.../core/service/LiveUpdateGuardTest.kt | 112 +++++++
.../core/testing/FakeLiveUpdatePrefs.kt | 29 ++
.../core/ui/util/LiveUpdateSupport.android.kt | 62 ++++
.../core/ui/util/LiveUpdateSupport.kt | 32 ++
.../core/ui/util/LiveUpdateSupport.ios.kt | 21 ++
.../core/ui/util/LiveUpdateSupport.jvm.kt | 21 ++
docs/en/user/connections.md | 13 +-
docs/en/user/notifications.md | 4 +-
.../connections/LiveUpdateViewModel.kt | 64 ++++
.../connections/ui/ConnectionsScreen.kt | 6 +
.../ui/components/LiveUpdateCard.kt | 316 ++++++++++++++++++
.../connections/LiveUpdateCandidatesTest.kt | 73 ++++
feature/node/detekt-baseline.xml | 2 +-
.../feature/node/component/DeviceActions.kt | 17 +-
.../feature/node/detail/NodeDetailContent.kt | 4 +
.../feature/node/detail/NodeDetailScreens.kt | 18 +
.../node/detail/NodeLiveUpdateViewModel.kt | 36 ++
.../feature/node/model/NodeDetailAction.kt | 3 +
.../node/navigation/NodesNavigation.kt | 1 +
29 files changed, 1537 insertions(+), 7 deletions(-)
create mode 100644 core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsImpl.kt
create mode 100644 core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsTest.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LiveUpdatePrefs.kt
create mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/LiveUpdateGuard.kt
create mode 100644 core/service/src/commonTest/kotlin/org/meshtastic/core/service/LiveUpdateGuardTest.kt
create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLiveUpdatePrefs.kt
create mode 100644 core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.android.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.kt
create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.ios.kt
create mode 100644 core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.jvm.kt
create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt
create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt
create mode 100644 feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/LiveUpdateCandidatesTest.kt
create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeLiveUpdateViewModel.kt
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 7e586220fe5..2f887d28625 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -906,6 +906,29 @@ licensed_mode_enable_warning
licensed_mode_enable_warning_signed
lightning_distance
lightning_strikes_1h
+### LIVE ###
+live_update
+live_update_direct
+live_update_distance
+live_update_heard
+live_update_heard_now
+live_update_hops
+live_update_mesh_status
+live_update_mesh_status_summary
+live_update_no_nodes
+live_update_nodes_online
+live_update_off_summary
+live_update_promotion_off
+live_update_search_nodes
+live_update_signal
+live_update_start
+live_update_stop
+live_update_switch
+live_update_track_in
+live_update_track_node
+live_update_track_node_summary
+live_update_tracking
+live_update_unread
### LOAD ###
load
load_15_min
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsImpl.kt
new file mode 100644
index 00000000000..140d787cd17
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsImpl.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.core.prefs.notification
+
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
+import org.meshtastic.core.prefs.di.UiDataStore
+import org.meshtastic.core.repository.LiveUpdateMode
+import org.meshtastic.core.repository.LiveUpdatePrefs
+
+@Single
+class LiveUpdatePrefsImpl(private val dataStore: UiDataStore, private val scope: ApplicationCoroutineScope) :
+ LiveUpdatePrefs {
+
+ override val mode: StateFlow =
+ dataStore.data
+ .map { LiveUpdateMode.decode(it[KEY_LIVE_UPDATE_MODE]) }
+ .stateIn(scope, SharingStarted.Eagerly, LiveUpdateMode.Off)
+
+ override fun setMode(mode: LiveUpdateMode) {
+ scope.launch { dataStore.edit { it[KEY_LIVE_UPDATE_MODE] = LiveUpdateMode.encode(mode) } }
+ }
+
+ private companion object {
+ val KEY_LIVE_UPDATE_MODE = stringPreferencesKey("live_update_mode")
+ }
+}
diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsTest.kt
new file mode 100644
index 00000000000..97bf757f29f
--- /dev/null
+++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/LiveUpdatePrefsTest.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.core.prefs.notification
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import okio.FileSystem
+import okio.Path
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
+import org.meshtastic.core.prefs.di.asUiDataStore
+import org.meshtastic.core.repository.LiveUpdateMode
+import org.meshtastic.core.repository.LiveUpdatePrefs
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.uuid.Uuid
+
+class LiveUpdatePrefsTest {
+ private lateinit var tmpDir: Path
+ private lateinit var dataStore: DataStore
+ private lateinit var prefs: LiveUpdatePrefs
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ @BeforeTest
+ fun setup() {
+ tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "liveUpdatePrefsTest-${Uuid.random()}"
+ FileSystem.SYSTEM.createDirectories(tmpDir)
+ dataStore =
+ PreferenceDataStoreFactory.createWithPath(
+ scope = testScope,
+ produceFile = { tmpDir / "test.preferences_pb" },
+ )
+ val appScope = object : ApplicationCoroutineScope, CoroutineScope by CoroutineScope(testDispatcher) {}
+ prefs = LiveUpdatePrefsImpl(dataStore.asUiDataStore(), appScope)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ FileSystem.SYSTEM.deleteRecursively(tmpDir)
+ }
+
+ @Test fun `mode defaults to off`() = testScope.runTest { assertEquals(LiveUpdateMode.Off, prefs.mode.value) }
+
+ @Test
+ fun `each mode round trips`() = testScope.runTest {
+ prefs.setMode(LiveUpdateMode.MeshStatus)
+ assertEquals(LiveUpdateMode.MeshStatus, prefs.mode.value)
+
+ prefs.setMode(LiveUpdateMode.TrackNode(-12345))
+ assertEquals(LiveUpdateMode.TrackNode(-12345), prefs.mode.value)
+
+ prefs.setMode(LiveUpdateMode.Off)
+ assertEquals(LiveUpdateMode.Off, prefs.mode.value)
+ }
+
+ @Test
+ fun `an unreadable stored value decodes to off`() {
+ assertEquals(LiveUpdateMode.Off, LiveUpdateMode.decode("node:abc"))
+ assertEquals(LiveUpdateMode.Off, LiveUpdateMode.decode("something"))
+ assertEquals(LiveUpdateMode.Off, LiveUpdateMode.decode(null))
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LiveUpdatePrefs.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LiveUpdatePrefs.kt
new file mode 100644
index 00000000000..05dfe69029c
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LiveUpdatePrefs.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.core.repository
+
+import kotlinx.coroutines.flow.StateFlow
+
+/** What the user-started Live Update shows. One mode at a time; [Off] leaves the service notification as it is. */
+sealed interface LiveUpdateMode {
+ data object Off : LiveUpdateMode
+
+ /** Connection, nodes online, unread messages and the connected radio's battery. */
+ data object MeshStatus : LiveUpdateMode
+
+ /** One node: its battery, signal, when it was last heard, hops and distance. */
+ data class TrackNode(val nodeNum: Int) : LiveUpdateMode
+
+ companion object {
+ private const val OFF = "off"
+ private const val MESH_STATUS = "mesh"
+ private const val TRACK_NODE_PREFIX = "node:"
+
+ fun encode(mode: LiveUpdateMode): String = when (mode) {
+ Off -> OFF
+ MeshStatus -> MESH_STATUS
+ is TrackNode -> TRACK_NODE_PREFIX + mode.nodeNum
+ }
+
+ /** Anything unreadable decodes to [Off], so a corrupt value can never leave a promoted notification up. */
+ fun decode(value: String?): LiveUpdateMode = when {
+ value == MESH_STATUS -> MeshStatus
+
+ value?.startsWith(TRACK_NODE_PREFIX) == true ->
+ value.removePrefix(TRACK_NODE_PREFIX).toIntOrNull()?.let(::TrackNode) ?: Off
+
+ else -> Off
+ }
+ }
+}
+
+/** The persisted [LiveUpdateMode]. Off by default. */
+interface LiveUpdatePrefs {
+ val mode: StateFlow
+
+ fun setMode(mode: LiveUpdateMode)
+}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index b2d90bbd962..ebaba101f03 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -942,6 +942,29 @@
Licensed mode removes channel encryption keys and disables the admin channel, so all traffic is sent as plaintext that anyone can read — but it is digitally signed, letting other nodes verify it came from you. Your node number may change once to match your identity key, so favorites, message history, remote admin, and other nodes may initially treat it as new; you remain responsible for meeting your amateur radio license requirements and local regulations.
Storm Distance
Lightning (1h)
+
+ Live Update
+ Direct
+ %1$s away
+ Heard %1$s
+ Heard just now
+ Hops away: %1$d
+ Mesh status
+ Connection, nodes online, unread messages and your radio's battery
+ No nodes to track
+ %1$d of %2$d nodes online
+ Show the mesh or one node in the status bar
+ Live Updates are turned off for Meshtastic in system settings
+ Search nodes
+ SNR %1$s, RSSI %2$s
+ Start
+ Stop
+ Switch
+ Track in Live Update
+ Track a node
+ One node's battery, signal, last heard, hops and distance
+ Tracking %1$s
+ %1$d unread
Load
Load 15m
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
index c4e8927ff37..93f57f1a760 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
@@ -51,6 +51,9 @@ import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.testing.FakeLiveUpdatePrefs
+import org.meshtastic.core.testing.FakeLocaleUnitsProvider
+import org.meshtastic.core.testing.FakeMeshPrefs
import org.meshtastic.core.testing.runUntilSettled
import org.meshtastic.core.testing.runWithRenderScope
import org.meshtastic.proto.ChannelSet
@@ -124,6 +127,9 @@ class MeshNotificationManagerImplConversationTest {
radioConfigRepository = lazy { radioConfigRepository },
radioOperationLock = RadioOperationLock(),
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
+ liveUpdatePrefs = FakeLiveUpdatePrefs(),
+ meshPrefs = FakeMeshPrefs(),
+ localeUnitsProvider = FakeLocaleUnitsProvider(),
scope = scope.asServiceScope(),
)
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplRoutingTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplRoutingTest.kt
index 3fe5928af23..07ccdd66a98 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplRoutingTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplRoutingTest.kt
@@ -39,6 +39,9 @@ import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.notificationId
+import org.meshtastic.core.testing.FakeLiveUpdatePrefs
+import org.meshtastic.core.testing.FakeLocaleUnitsProvider
+import org.meshtastic.core.testing.FakeMeshPrefs
import org.meshtastic.core.testing.runWithRenderScope
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DuplicatedPublicKey
@@ -381,6 +384,9 @@ class MeshNotificationManagerImplRoutingTest {
radioConfigRepository = lazy { error("Not used in this test") },
radioOperationLock = RadioOperationLock(),
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
+ liveUpdatePrefs = FakeLiveUpdatePrefs(),
+ meshPrefs = FakeMeshPrefs(),
+ localeUnitsProvider = FakeLocaleUnitsProvider(),
scope = scope.asServiceScope(),
)
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
index f749950133a..b960ef2b8e2 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
@@ -20,6 +20,7 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
+import android.content.pm.ActivityInfo
import androidx.core.app.NotificationCompat
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -30,6 +31,7 @@ import dev.mokkery.mock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.withContext
import org.junit.After
@@ -40,8 +42,10 @@ import org.meshtastic.core.common.di.asServiceScope
import org.meshtastic.core.common.state.RadioOperationLock
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.FirmwareUpdateProgress
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
+import org.meshtastic.core.repository.LiveUpdateMode
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
@@ -50,13 +54,22 @@ import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.firmware_update_in_progress
import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.live_update_nodes_online
+import org.meshtastic.core.resources.live_update_unread
import org.meshtastic.core.resources.local_stats_nodes
+import org.meshtastic.core.testing.FakeLiveUpdatePrefs
+import org.meshtastic.core.testing.FakeLocaleUnitsProvider
+import org.meshtastic.core.testing.FakeMeshPrefs
import org.meshtastic.core.testing.runUntilSettled
import org.meshtastic.core.testing.runWithRenderScope
+import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
+import org.meshtastic.proto.User
+import org.robolectric.Shadows
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -68,7 +81,9 @@ class MeshNotificationManagerImplTest {
private lateinit var context: Context
private lateinit var systemNotificationManager: NotificationManager
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
+ private val liveUpdatePrefs = FakeLiveUpdatePrefs()
@Before
fun setUp() {
@@ -77,6 +92,14 @@ class MeshNotificationManagerImplTest {
systemNotificationManager.cancelAll()
clearManagedChannels()
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ // A Live Update's tap target is a deep link, whose task stack the platform resolves against MainActivity.
+ val mainActivity =
+ ActivityInfo().apply {
+ name = "org.meshtastic.app.MainActivity"
+ packageName = context.packageName
+ exported = true
+ }
+ Shadows.shadowOf(context.packageManager).addOrUpdateActivity(mainActivity)
}
@After
@@ -267,17 +290,134 @@ class MeshNotificationManagerImplTest {
assertTrue(text.contains(getString(Res.string.local_stats_nodes, 2, 3)))
}
+ @Test
+ fun `the service notification is not promoted while the live update is off`() = runWithRenderScope { renderScope ->
+ val notifications = createManager(renderScope)
+ notifications.initChannels()
+
+ notifications.updateServiceStateNotification(ConnectionState.Connected, populatedTelemetry())
+ runUntilSettled { activeServiceNotification() != null }
+
+ val posted = assertNotNull(activeServiceNotification()).notification
+ assertFalse(NotificationCompat.isRequestPromotedOngoing(posted))
+ assertNull(posted.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
+ }
+
+ @Test
+ fun `mesh status promotes the service notification until it is turned off`() = runWithRenderScope { renderScope ->
+ stubMeshStatus(online = 3, total = 12, unread = 2)
+ val notifications = createManager(renderScope)
+ notifications.initChannels()
+ notifications.updateServiceStateNotification(ConnectionState.Connected, populatedTelemetry())
+ runUntilSettled { activeServiceNotification() != null }
+ val offTitle = serviceTitle()
+
+ liveUpdatePrefs.setMode(LiveUpdateMode.MeshStatus)
+ runUntilSettled {
+ activeServiceNotification()?.notification?.let(NotificationCompat::isRequestPromotedOngoing) == true
+ }
+
+ val posted = assertNotNull(activeServiceNotification()).notification
+ assertEquals("3/12", posted.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
+ assertEquals(getString(Res.string.live_update_nodes_online, 3, 12), serviceBigText()?.lineSequence()?.first())
+ assertTrue(serviceBigText().orEmpty().contains(getString(Res.string.live_update_unread, 2)))
+ assertEquals(offTitle, serviceTitle())
+
+ liveUpdatePrefs.setMode(LiveUpdateMode.Off)
+ runUntilSettled {
+ activeServiceNotification()?.notification?.let(NotificationCompat::isRequestPromotedOngoing) == false
+ }
+ assertNull(serviceExtras()?.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
+ assertEquals(offTitle, serviceTitle())
+ }
+
+ @Test
+ fun `firmware progress outranks a running live update`() = runWithRenderScope { renderScope ->
+ stubMeshStatus(online = 1, total = 1, unread = 0)
+ liveUpdatePrefs.setMode(LiveUpdateMode.MeshStatus)
+ val notifications = createManager(renderScope)
+ notifications.initChannels()
+ notifications.updateServiceStateNotification(ConnectionState.Connected, populatedTelemetry())
+ runUntilSettled { serviceExtras()?.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) == "1/1" }
+
+ firmwareUpdateStatusRepository.publishProgress(
+ FirmwareUpdateProgress(UiText.DynamicString("Writing firmware"), percent = 42),
+ )
+ runUntilSettled { serviceExtras()?.getInt(Notification.EXTRA_PROGRESS) == 42 }
+
+ assertEquals(getString(Res.string.firmware_update_in_progress), serviceTitle())
+ assertEquals("42%", serviceExtras()?.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
+
+ firmwareUpdateStatusRepository.publishProgress(null)
+ runUntilSettled { serviceExtras()?.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) == "1/1" }
+ }
+
+ @Test
+ fun `a tracked node is shown until it is deleted and then the live update turns off`() =
+ runWithRenderScope { renderScope ->
+ val tracked =
+ Node(
+ num = TRACKED_NUM,
+ user =
+ User.Builder()
+ .also { wb ->
+ wb.long_name = "Base camp"
+ wb.short_name = "BASE"
+ }
+ .build(),
+ deviceMetrics = DeviceMetrics.Builder().also { wb -> wb.battery_level = 77 }.build(),
+ )
+ val db = MutableStateFlow(mapOf(TRACKED_NUM to tracked))
+ every { nodeRepository.nodeDBbyNum } returns db
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ val notifications = createManager(renderScope)
+ notifications.initChannels()
+ notifications.updateServiceStateNotification(ConnectionState.Disconnected, populatedTelemetry())
+ runUntilSettled { activeServiceNotification() != null }
+
+ liveUpdatePrefs.setMode(LiveUpdateMode.TrackNode(TRACKED_NUM))
+ runUntilSettled { serviceTitle() == "Base camp" }
+
+ val posted = assertNotNull(activeServiceNotification()).notification
+ assertTrue(NotificationCompat.isRequestPromotedOngoing(posted))
+ assertEquals("77%", posted.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
+ // A dropped link keeps the mode and says so.
+ assertEquals(
+ getString(Res.string.disconnected),
+ posted.extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString(),
+ )
+
+ db.value = emptyMap()
+ runUntilSettled { liveUpdatePrefs.mode.value == LiveUpdateMode.Off }
+ runUntilSettled {
+ activeServiceNotification()?.notification?.let(NotificationCompat::isRequestPromotedOngoing) == false
+ }
+ assertEquals(getString(Res.string.disconnected), serviceTitle())
+ }
+
+ private fun stubMeshStatus(online: Int, total: Int, unread: Int) {
+ every { nodeRepository.onlineNodeCount } returns flowOf(online)
+ every { nodeRepository.totalNodeCount } returns flowOf(total)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { packetRepository.getUnreadCountTotal() } returns flowOf(unread)
+ }
+
private fun createManager(scope: CoroutineScope) = MeshNotificationManagerImpl(
context = context,
- packetRepository = lazy { error("Not used in this test") },
+ packetRepository = lazy { packetRepository },
nodeRepository = lazy { nodeRepository },
conversationShortcutPublisher = lazy { error("Not used in this test") },
radioConfigRepository = lazy { error("Not used in this test") },
radioOperationLock = RadioOperationLock(),
firmwareUpdateStatusRepository = firmwareUpdateStatusRepository,
+ liveUpdatePrefs = liveUpdatePrefs,
+ meshPrefs = FakeMeshPrefs(),
+ localeUnitsProvider = FakeLocaleUnitsProvider(),
scope = scope.asServiceScope(),
)
+ private fun serviceBigText() = serviceExtras()?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
+
private fun populatedTelemetry() = Telemetry.Builder()
.also { wb ->
wb.local_stats =
@@ -304,6 +444,10 @@ class MeshNotificationManagerImplTest {
)
}
+ private companion object {
+ const val TRACKED_NUM = 42
+ }
+
private fun clearManagedChannels() {
val channelIds = NotificationChannels.LEGACY_CATEGORY_IDS + NotificationChannelSpec.entries.map { it.id }
channelIds.forEach { channelId -> systemNotificationManager.deleteNotificationChannel(channelId) }
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
index 6b41ff17ec9..29450d7cf2c 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
@@ -31,10 +31,20 @@ import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -44,9 +54,12 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.common.state.RadioOperation
import org.meshtastic.core.common.state.RadioOperationLock
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.LocaleUnitsProvider
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
@@ -56,10 +69,14 @@ import org.meshtastic.core.model.Message
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.noiseFloorOrNull
+import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.repository.FirmwareUpdateProgress
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
+import org.meshtastic.core.repository.LiveUpdateMode
+import org.meshtastic.core.repository.LiveUpdatePrefs
import org.meshtastic.core.repository.MeshNotificationManager
+import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -78,6 +95,14 @@ import org.meshtastic.core.resources.firmware_update_in_progress
import org.meshtastic.core.resources.firmware_update_notification_android
import org.meshtastic.core.resources.formatDurationSuspend
import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.resources.live_update_direct
+import org.meshtastic.core.resources.live_update_distance
+import org.meshtastic.core.resources.live_update_heard
+import org.meshtastic.core.resources.live_update_heard_now
+import org.meshtastic.core.resources.live_update_hops
+import org.meshtastic.core.resources.live_update_nodes_online
+import org.meshtastic.core.resources.live_update_signal
+import org.meshtastic.core.resources.live_update_unread
import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_battery
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
@@ -107,8 +132,10 @@ import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
import org.meshtastic.core.repository.Notification as MeshNotification
/**
@@ -118,6 +145,7 @@ import org.meshtastic.core.repository.Notification as MeshNotification
* notifications for various events like new messages, alerts, and service status changes.
*/
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
+@OptIn(ExperimentalCoroutinesApi::class)
@Single
class MeshNotificationManagerImpl(
private val context: Context,
@@ -127,6 +155,9 @@ class MeshNotificationManagerImpl(
private val radioConfigRepository: Lazy,
private val radioOperationLock: RadioOperationLock,
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
+ private val liveUpdatePrefs: LiveUpdatePrefs,
+ private val meshPrefs: MeshPrefs,
+ private val localeUnitsProvider: LocaleUnitsProvider,
private val scope: ServiceScope,
) : MeshNotificationManager {
@@ -172,6 +203,13 @@ class MeshNotificationManagerImpl(
private val CHANNEL_LABEL_TIMEOUT = 2.seconds
private const val MAIN_ACTIVITY_CLASS = "org.meshtastic.app.MainActivity"
private const val THUMBS_UP = "👍"
+ private const val CONNECTIONS_REQUEST_CODE = 0x4C55
+ private const val LIVE_UPDATE_SEPARATOR = " · "
+ private val LIVE_UPDATE_MIN_INTERVAL = 2.seconds
+ private const val SECONDS_PER_MINUTE = 60L
+ private const val SECONDS_PER_HOUR = 3_600L
+ private const val SECONDS_PER_DAY = 86_400L
+ private const val MILLIS_PER_SECOND = 1_000L
}
private data class ServiceNotificationSnapshot(
@@ -182,8 +220,17 @@ class MeshNotificationManagerImpl(
val nextUpdateAt: Long,
val activeOperations: Set = emptySet(),
val firmwareProgress: FirmwareUpdateProgress? = null,
+ val liveUpdate: LiveUpdateReading? = null,
)
+ /** What a running Live Update shows; null while it is off. */
+ private sealed interface LiveUpdateReading {
+ data class MeshStatus(val nodesOnline: Int, val nodesTotal: Int, val unread: Int, val radioBattery: Int?) :
+ LiveUpdateReading
+
+ data class TrackNode(val node: Node, val ourNode: Node?) : LiveUpdateReading
+ }
+
/** [message] is the stats text to fall back on later; a firmware-progress render keeps the previous one. */
private data class RenderedServiceNotification(val notification: Notification, val message: String?)
@@ -292,6 +339,7 @@ class MeshNotificationManagerImpl(
private var nextStatsUpdateMillis: Long = 0
private var cachedMessage: String? = null
private var cachedServiceNotification: Notification? = null
+ private var cachedLiveUpdate: LiveUpdateReading? = null
private val serviceNotificationSnapshots =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@@ -315,6 +363,52 @@ class MeshNotificationManagerImpl(
}
}
}
+ scope.launch {
+ liveUpdatePrefs.mode.flatMapLatest(::liveUpdateReadings).distinctUntilChanged().collect { reading ->
+ synchronized(serviceNotificationLock) {
+ cachedLiveUpdate = reading
+ val last = serviceNotificationSnapshots.replayCache.lastOrNull() ?: return@synchronized
+ serviceNotificationSnapshots.tryEmit(last.copy(liveUpdate = reading))
+ }
+ }
+ }
+ scope.launch {
+ LiveUpdateGuard(
+ prefs = liveUpdatePrefs,
+ nodes = { nodeRepository.value.nodeDBbyNum },
+ selectedAddress = meshPrefs.deviceAddress,
+ radioOperationLock = radioOperationLock,
+ )
+ .run()
+ }
+ }
+
+ private fun liveUpdateReadings(mode: LiveUpdateMode): Flow = when (mode) {
+ LiveUpdateMode.Off -> flowOf(null)
+
+ LiveUpdateMode.MeshStatus -> {
+ val nodes = nodeRepository.value
+ combine(
+ nodes.onlineNodeCount,
+ nodes.totalNodeCount,
+ packetRepository.value.getUnreadCountTotal(),
+ nodes.ourNodeInfo.map { it?.deviceMetrics?.battery_level }.distinctUntilChanged(),
+ ) { online, total, unread, battery ->
+ LiveUpdateReading.MeshStatus(online, total, unread, battery)
+ }
+ .throttleLatest(LIVE_UPDATE_MIN_INTERVAL)
+ }
+
+ is LiveUpdateMode.TrackNode -> {
+ val nodes = nodeRepository.value
+ combine(nodes.nodeDBbyNum.map { it[mode.nodeNum] }.distinctUntilChanged(), nodes.ourNodeInfo) {
+ node,
+ ourNode,
+ ->
+ node?.let { LiveUpdateReading.TrackNode(it, ourNode) }
+ }
+ .throttleLatest(LIVE_UPDATE_MIN_INTERVAL)
+ }
}
/**
@@ -399,6 +493,7 @@ class MeshNotificationManagerImpl(
nextUpdateAt = nextStatsUpdateMillis,
activeOperations = radioOperationLock.activeOperations,
firmwareProgress = firmwareUpdateStatusRepository.progress.value,
+ liveUpdate = cachedLiveUpdate,
)
}
@@ -427,6 +522,20 @@ class MeshNotificationManagerImpl(
is ConnectionState.Connecting -> getStringSuspend(Res.string.connecting)
}
+ return snapshot.liveUpdate?.let { reading ->
+ val notification =
+ when (reading) {
+ is LiveUpdateReading.MeshStatus -> createMeshStatusLiveUpdate(title, snapshot.state, reading)
+ is LiveUpdateReading.TrackNode -> createTrackedNodeLiveUpdate(title, snapshot.state, reading)
+ }
+ RenderedServiceNotification(notification, message = snapshot.previousMessage)
+ } ?: renderServiceStateNotification(title, snapshot)
+ }
+
+ private suspend fun renderServiceStateNotification(
+ title: String,
+ snapshot: ServiceNotificationSnapshot,
+ ): RenderedServiceNotification {
val freshMessage =
when {
snapshot.localStats != null ->
@@ -817,6 +926,120 @@ class MeshNotificationManagerImpl(
.build()
}
+ private suspend fun createMeshStatusLiveUpdate(
+ title: String,
+ state: ConnectionState,
+ reading: LiveUpdateReading.MeshStatus,
+ ): Notification {
+ val lines = buildList {
+ add(getStringSuspend(Res.string.live_update_nodes_online, reading.nodesOnline, reading.nodesTotal))
+ add(getStringSuspend(Res.string.live_update_unread, reading.unread))
+ reading.radioBattery?.let { add(batteryText(it)) }
+ }
+ val chip = "${reading.nodesOnline}/${reading.nodesTotal}".takeIf { state is ConnectionState.Connected }
+ return createLiveUpdateNotification(
+ title = title,
+ subText = null,
+ lines = lines,
+ chip = chip,
+ contentIntent = createDeepLinkIntent("connections", CONNECTIONS_REQUEST_CODE),
+ )
+ }
+
+ private suspend fun createTrackedNodeLiveUpdate(
+ connectionTitle: String,
+ state: ConnectionState,
+ reading: LiveUpdateReading.TrackNode,
+ ): Notification {
+ val node = reading.node
+ val battery = node.deviceMetrics.battery_level
+ val heardSecondsAgo = (nowSeconds - node.lastHeard).coerceAtLeast(0).takeIf { node.lastHeard > 0 }
+ val lines = buildList {
+ battery?.let { add(batteryText(it)) }
+ if (node.snrOrNull != null || node.rssiOrNull != null) {
+ add(
+ getStringSuspend(
+ Res.string.live_update_signal,
+ MetricFormatter.snr(node.snrOrNull),
+ MetricFormatter.rssi(node.rssiOrNull),
+ ),
+ )
+ }
+ heardSecondsAgo?.let { add(heardText(node.lastHeard, it)) }
+ when {
+ node.hopsAway == 0 -> add(getStringSuspend(Res.string.live_update_direct))
+ node.hopsAway > 0 -> add(getStringSuspend(Res.string.live_update_hops, node.hopsAway))
+ }
+ reading.ourNode
+ ?.takeIf { it.num != node.num }
+ ?.let { node.distance(it) }
+ ?.let {
+ val distance = it.toDistanceString(localeUnitsProvider.measurementSystem.value)
+ add(getStringSuspend(Res.string.live_update_distance, distance))
+ }
+ }
+ val chip =
+ battery?.takeIf { it <= MAX_BATTERY_LEVEL }?.let { MetricFormatter.percent(it) }
+ ?: heardSecondsAgo?.let { formatDurationSuspend(it, largestUnit(it)) }
+ return createLiveUpdateNotification(
+ title =
+ node.user.long_name.ifBlank { node.user.short_name }.ifBlank { NodeAddress.numToDefaultId(node.num) },
+ subText = connectionTitle.takeIf { state !is ConnectionState.Connected },
+ lines = lines,
+ chip = chip,
+ contentIntent = createOpenNodeDetailIntent(node.num),
+ )
+ }
+
+ /**
+ * A user-started Live Update on the service notification. Promotion needs an ongoing notification with a title, no
+ * custom views and no colorization, on a channel above MIN; BigTextStyle is one of the allowed styles.
+ */
+ private fun createLiveUpdateNotification(
+ title: String,
+ subText: String?,
+ lines: List,
+ chip: String?,
+ contentIntent: PendingIntent,
+ ): Notification {
+ val builder =
+ commonBuilder(NotificationChannelSpec.Service, contentIntent)
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
+ .setContentTitle(title)
+ .setContentText(lines.take(2).joinToString(LIVE_UPDATE_SEPARATOR))
+ .setStyle(NotificationCompat.BigTextStyle().bigText(lines.joinToString("\n")))
+ .setShowWhen(false)
+ .setRequestPromotedOngoing(true)
+ subText?.let { builder.setSubText(it) }
+ chip?.let { builder.setShortCriticalText(it) }
+ return builder.build()
+ }
+
+ private suspend fun batteryText(level: Int): String = if (level > MAX_BATTERY_LEVEL) {
+ getStringSuspend(Res.string.powered)
+ } else {
+ getStringSuspend(Res.string.local_stats_battery, level)
+ }
+
+ private suspend fun heardText(lastHeard: Int, secondsAgo: Long): String = if (secondsAgo < SECONDS_PER_MINUTE) {
+ getStringSuspend(Res.string.live_update_heard_now)
+ } else {
+ getStringSuspend(
+ Res.string.live_update_heard,
+ DateFormatter.formatRelativeTime(lastHeard.toLong() * MILLIS_PER_SECOND),
+ )
+ }
+
+ /** One unit is enough for the status-bar chip, which the platform cuts at a few characters. */
+ private fun largestUnit(seconds: Long): DurationUnit = when {
+ seconds < SECONDS_PER_HOUR -> DurationUnit.MINUTES
+ seconds < SECONDS_PER_DAY -> DurationUnit.HOURS
+ else -> DurationUnit.DAYS
+ }
+
@Suppress("LongMethod")
private suspend fun createConversationNotification(
contactKey: String,
@@ -1283,3 +1506,9 @@ internal fun Set.notificationTitleResource(): StringResource? =
else -> Res.string.discovery_scan_in_progress
}
+
+/** Passes the first value at once, then at most one per [period], always the latest. */
+private fun Flow.throttleLatest(period: Duration): Flow = conflate().transform {
+ emit(it)
+ delay(period)
+}
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/LiveUpdateGuard.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/LiveUpdateGuard.kt
new file mode 100644
index 00000000000..80fc71daabd
--- /dev/null
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/LiveUpdateGuard.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.core.service
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.meshtastic.core.common.state.RadioOperation
+import org.meshtastic.core.common.state.RadioOperationLock
+import org.meshtastic.core.common.util.isValidDeviceAddress
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.LiveUpdateMode
+import org.meshtastic.core.repository.LiveUpdatePrefs
+
+/**
+ * Turns a Live Update off once what it shows is gone: the tracked node was deleted or ignored, or the user disconnected
+ * or switched radios. A dropped link changes neither, so the mode survives it and the notification shows the state.
+ */
+class LiveUpdateGuard(
+ private val prefs: LiveUpdatePrefs,
+ private val nodes: () -> Flow