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>, + private val selectedAddress: Flow, + private val radioOperationLock: RadioOperationLock, +) { + suspend fun run() = coroutineScope { + launch { watchTrackedNode() } + launch { watchSelection() } + } + + private suspend fun watchTrackedNode() { + prefs.mode.collectLatest { mode -> + if (mode !is LiveUpdateMode.TrackNode) return@collectLatest + var seen = false + nodes().collect { db -> + val node = db[mode.nodeNum] + if (trackedNodeEnds(node, seen)) { + prefs.setMode(LiveUpdateMode.Off) + } else if (node != null) { + seen = true + } + } + } + } + + private suspend fun watchSelection() { + var previous: String? = null + selectedAddress.collect { current -> + val firmwareBusy = + radioOperationLock.activeOperations.any { + it == RadioOperation.FirmwareUpdate || it == RadioOperation.FirmwareMaintenance + } + if (prefs.mode.value != LiveUpdateMode.Off && selectionEndsLiveUpdate(previous, current, firmwareBusy)) { + prefs.setMode(LiveUpdateMode.Off) + } + previous = current + } + } +} + +/** + * Whether a tracked node's latest row ends tracking. A node absent before it was ever seen is the database still + * loading, not a deletion, so only a node that disappears after being seen counts. + */ +internal fun trackedNodeEnds(node: Node?, seenBefore: Boolean): Boolean = node?.isIgnored ?: seenBefore + +/** + * Whether a change of the selected radio ends a Live Update. The firmware flow deselects and reselects the radio around + * a flash, which is not the user leaving it. + */ +internal fun selectionEndsLiveUpdate(previous: String?, current: String?, firmwareBusy: Boolean): Boolean = + !firmwareBusy && isValidDeviceAddress(previous) && current != previous diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/LiveUpdateGuardTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/LiveUpdateGuardTest.kt new file mode 100644 index 00000000000..ae4b4843e81 --- /dev/null +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/LiveUpdateGuardTest.kt @@ -0,0 +1,112 @@ +/* + * 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.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.common.state.RadioOperation +import org.meshtastic.core.common.state.RadioOperationLock +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.LiveUpdateMode +import org.meshtastic.core.testing.FakeLiveUpdatePrefs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LiveUpdateGuardTest { + private val tracked = Node(num = 42) + private val radio = "x11:22:33:44:55:66" + + private val prefs = FakeLiveUpdatePrefs(LiveUpdateMode.TrackNode(tracked.num)) + private val nodes = MutableStateFlow>(emptyMap()) + private val address = MutableStateFlow(null) + private val lock = RadioOperationLock() + + private fun guard() = LiveUpdateGuard(prefs, { nodes }, address, lock) + + @Test + fun `a node not yet loaded keeps tracking`() { + assertFalse(trackedNodeEnds(node = null, seenBefore = false)) + } + + @Test + fun `a node that disappears after being seen ends tracking`() { + assertTrue(trackedNodeEnds(node = null, seenBefore = true)) + } + + @Test + fun `an ignored node ends tracking`() { + assertTrue(trackedNodeEnds(node = tracked.copy(isIgnored = true), seenBefore = false)) + } + + @Test + fun `tracking reverts to off when the node is deleted`() = runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { guard().run() } + + nodes.value = mapOf(tracked.num to tracked) + assertEquals(LiveUpdateMode.TrackNode(tracked.num), prefs.mode.value) + + nodes.value = emptyMap() + assertEquals(LiveUpdateMode.Off, prefs.mode.value) + } + + @Test + fun `tracking reverts to off when the node is ignored`() = runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { guard().run() } + + nodes.value = mapOf(tracked.num to tracked.copy(isIgnored = true)) + assertEquals(LiveUpdateMode.Off, prefs.mode.value) + } + + @Test + fun `a user disconnect ends the live update`() = runTest(UnconfinedTestDispatcher()) { + prefs.setMode(LiveUpdateMode.MeshStatus) + address.value = radio + backgroundScope.launch { guard().run() } + + address.value = "n" + assertEquals(LiveUpdateMode.Off, prefs.mode.value) + } + + @Test + fun `a radio switch ends the live update`() { + assertTrue(selectionEndsLiveUpdate(previous = radio, current = "t10.0.2.2:4403", firmwareBusy = false)) + } + + @Test + fun `selecting a radio from none does not end the live update`() { + assertFalse(selectionEndsLiveUpdate(previous = null, current = radio, firmwareBusy = false)) + assertFalse(selectionEndsLiveUpdate(previous = "n", current = radio, firmwareBusy = false)) + } + + @Test + fun `the firmware flow deselecting the radio keeps the live update`() = runTest(UnconfinedTestDispatcher()) { + prefs.setMode(LiveUpdateMode.MeshStatus) + address.value = radio + backgroundScope.launch { guard().run() } + + val lease = lock.acquire(RadioOperation.FirmwareUpdate) + address.value = "n" + address.value = radio + lock.release(lease) + + assertEquals(LiveUpdateMode.MeshStatus, prefs.mode.value) + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLiveUpdatePrefs.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLiveUpdatePrefs.kt new file mode 100644 index 00000000000..090411550b8 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLiveUpdatePrefs.kt @@ -0,0 +1,29 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.MutableStateFlow +import org.meshtastic.core.repository.LiveUpdateMode +import org.meshtastic.core.repository.LiveUpdatePrefs + +class FakeLiveUpdatePrefs(initial: LiveUpdateMode = LiveUpdateMode.Off) : LiveUpdatePrefs { + override val mode = MutableStateFlow(initial) + + override fun setMode(mode: LiveUpdateMode) { + this.mode.value = mode + } +} diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.android.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.android.kt new file mode 100644 index 00000000000..705ebcc3be0 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.android.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.core.ui.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import co.touchlab.kermit.Logger + +@Composable +actual fun rememberLiveUpdateSupport(): LiveUpdateSupport { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) return LiveUpdateSupport.Unavailable + val context = LocalContext.current + val turnedOff = remember(context) { mutableStateOf(!context.canPostPromotedNotifications()) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { turnedOff.value = !context.canPostPromotedNotifications() } + val openSettings = remember(context) { { context.openPromotionSettings() } } + return LiveUpdateSupport(available = true, turnedOff = turnedOff.value, openSettings = openSettings) +} + +private fun Context.canPostPromotedNotifications(): Boolean = + NotificationManagerCompat.from(this).canPostPromotedNotifications() + +private fun Context.openPromotionSettings() { + val actions = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + add(Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS) + } + add(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + } + for (action in actions) { + val intent = Intent(action).putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + try { + startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + return + } catch (ex: ActivityNotFoundException) { + Logger.w(ex) { "No activity for ${intent.action}" } + } + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.kt new file mode 100644 index 00000000000..76925894dbd --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.kt @@ -0,0 +1,32 @@ +/* + * 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.ui.util + +import androidx.compose.runtime.Composable + +/** + * Whether this device can show a Live Update (Android 16 and later), and whether the user has turned them off for the + * app in system settings, where [openSettings] takes them. + */ +data class LiveUpdateSupport(val available: Boolean, val turnedOff: Boolean, val openSettings: () -> Unit) { + companion object { + val Unavailable = LiveUpdateSupport(available = false, turnedOff = false, openSettings = {}) + } +} + +/** The current [LiveUpdateSupport], re-read whenever the screen resumes. */ +@Composable expect fun rememberLiveUpdateSupport(): LiveUpdateSupport diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.ios.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.ios.kt new file mode 100644 index 00000000000..a1b38e64f81 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.ios.kt @@ -0,0 +1,21 @@ +/* + * 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.ui.util + +import androidx.compose.runtime.Composable + +@Composable actual fun rememberLiveUpdateSupport(): LiveUpdateSupport = LiveUpdateSupport.Unavailable diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.jvm.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.jvm.kt new file mode 100644 index 00000000000..a1b38e64f81 --- /dev/null +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/LiveUpdateSupport.jvm.kt @@ -0,0 +1,21 @@ +/* + * 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.ui.util + +import androidx.compose.runtime.Composable + +@Composable actual fun rememberLiveUpdateSupport(): LiveUpdateSupport = LiveUpdateSupport.Unavailable diff --git a/docs/en/user/connections.md b/docs/en/user/connections.md index 80b447e98fa..b902ea1b54f 100644 --- a/docs/en/user/connections.md +++ b/docs/en/user/connections.md @@ -2,7 +2,7 @@ title: Connections parent: User Guide nav_order: 2 -last_updated: 2026-09-29 +last_updated: 2026-10-18 description: Connect your phone or desktop to a Meshtastic radio via Bluetooth, USB, or TCP/IP. aliases: - bluetooth @@ -138,6 +138,17 @@ Once the region is set, the tab warns you if the radio is receive-only: a **Tran For more information, see [Settings — Radio & User](settings-radio-user#lora-config). +## Live Update + +On Android 16 and newer, the **Connect** tab has a **Live Update** row while you are connected. It puts one thing you choose in the status bar and the service notification: + +- **Mesh status:** the connection, how many nodes are online, your unread messages and your radio's battery. The status bar shows the nodes online, such as `5/12`. +- **Track a node:** one node's battery, signal, when it was last heard, its hops and its distance from your radio. The status bar shows its battery, or how long ago it was heard when it reports no battery. Pick the node from a list with favorites first, then the most recently heard, or search it by name. + +Tap the row to start one, switch to the other, or stop it. Only one runs at a time, and it is off until you start it. On a node's details, **Track in Live Update** starts tracking that node directly. + +A dropped link keeps it running and shows the connection state until the radio is back. Disconnecting, switching radios, or deleting or ignoring the tracked node stops it. If the row says Live Updates are turned off for Meshtastic, Android shows it as an ordinary notification until you allow them in system settings. + ## Reconnection Behavior The app reconnects to the last selected radio on startup. You can switch transports from the **Connect** tab at any time. diff --git a/docs/en/user/notifications.md b/docs/en/user/notifications.md index 6e4574e8b02..e3fba2aa1a8 100644 --- a/docs/en/user/notifications.md +++ b/docs/en/user/notifications.md @@ -2,7 +2,7 @@ title: Notifications parent: User Guide nav_order: 18 -last_updated: 2026-09-28 +last_updated: 2026-10-18 description: What each Meshtastic notification is for, how to silence one kind without the others, and what you can do from a message notification or a watch. aliases: - notifications @@ -37,7 +37,7 @@ By default, direct messages, alerts and radio notices pop up on screen, mesh inv Alert notifications play an alarm sound. To let them through Do Not Disturb as well, open the category and allow it to override Do Not Disturb, which is the step [Getting Started](onboarding#critical-alerts-permission) offers during setup. -The service notification stays in the shade while Meshtastic is connected in the background. It shows the connection state, and also shows progress while a firmware update or a preset scan runs. +The service notification stays in the shade while Meshtastic is connected in the background. It shows the connection state, and also shows progress while a firmware update or a preset scan runs. On Android 16 and newer you can turn it into a [Live Update](connections#live-update) that shows your mesh or one node in the status bar. > ℹ️ **Note:** The Desktop app has no notification categories. It switches notifications from its own settings instead, described in [Notification Preferences](desktop#notification-preferences). diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt new file mode 100644 index 00000000000..ef2780eeb97 --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt @@ -0,0 +1,64 @@ +/* + * 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.connections + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.LiveUpdateMode +import org.meshtastic.core.repository.LiveUpdatePrefs +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed + +/** Starts, switches and stops the Live Update from the Connections screen. */ +@KoinViewModel +class LiveUpdateViewModel(private val prefs: LiveUpdatePrefs, nodeRepository: NodeRepository) : ViewModel() { + val mode: StateFlow = prefs.mode + + /** Nodes the user can track, in the order the chooser lists them. */ + val candidates: StateFlow> = + combine(nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) { db, myNodeInfo -> + liveUpdateCandidates(db.values, myNodeInfo?.myNodeNum) + } + .stateInWhileSubscribed(emptyList()) + + val trackedNode: StateFlow = + combine(prefs.mode, nodeRepository.nodeDBbyNum) { mode, db -> + (mode as? LiveUpdateMode.TrackNode)?.let { db[it.nodeNum] } + } + .stateInWhileSubscribed(null) + + fun setMode(mode: LiveUpdateMode) = prefs.setMode(mode) +} + +/** Every node but our own and ignored ones: favorites first, then the most recently heard. */ +fun liveUpdateCandidates(nodes: Collection, ourNodeNum: Int?): List = nodes + .filter { it.num != ourNodeNum && !it.isIgnored } + .sortedWith(compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }.thenBy { it.num }) + +/** [candidates] whose long name, short name or node id contains [query], ignoring case. */ +fun filterLiveUpdateCandidates(candidates: List, query: String): List { + val needle = query.trim() + if (needle.isEmpty()) return candidates + return candidates.filter { node -> + node.user.long_name.contains(needle, ignoreCase = true) || + node.user.short_name.contains(needle, ignoreCase = true) || + node.user.id.contains(needle, ignoreCase = true) + } +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 453490886d9..afa18bd160f 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -143,6 +143,7 @@ import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedInfo import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedText import org.meshtastic.feature.connections.ui.components.DeviceList import org.meshtastic.feature.connections.ui.components.EventFirmwareCard +import org.meshtastic.feature.connections.ui.components.LiveUpdateSection import org.meshtastic.feature.connections.ui.components.TransportSelector import org.meshtastic.feature.connections.ui.components.loraSummary @@ -609,6 +610,11 @@ fun ConnectionsScreen( onConfigNavigate = onConfigNavigate, ) + LiveUpdateSection( + connected = uiState == ConnectionUiState.CONNECTED_WITH_NODE, + modifier = Modifier.padding(top = 8.dp), + ) + // Transport selector sits between the connection card and device list; it controls only the // visible discovery pane, not the globally selected/connected device shown above. // With Network as the only pane left, a one-segment control would select nothing. diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt new file mode 100644 index 00000000000..60bc1a13af9 --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt @@ -0,0 +1,316 @@ +/* + * 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.connections.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.repository.LiveUpdateMode +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.favorite +import org.meshtastic.core.resources.live_update +import org.meshtastic.core.resources.live_update_mesh_status +import org.meshtastic.core.resources.live_update_mesh_status_summary +import org.meshtastic.core.resources.live_update_no_nodes +import org.meshtastic.core.resources.live_update_off_summary +import org.meshtastic.core.resources.live_update_promotion_off +import org.meshtastic.core.resources.live_update_search_nodes +import org.meshtastic.core.resources.live_update_start +import org.meshtastic.core.resources.live_update_stop +import org.meshtastic.core.resources.live_update_switch +import org.meshtastic.core.resources.live_update_track_node +import org.meshtastic.core.resources.live_update_track_node_summary +import org.meshtastic.core.resources.live_update_tracking +import org.meshtastic.core.resources.open_settings +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.icon.Favorite +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Notifications +import org.meshtastic.core.ui.icon.Search +import org.meshtastic.core.ui.util.formatAgo +import org.meshtastic.core.ui.util.rememberLiveUpdateSupport +import org.meshtastic.feature.connections.LiveUpdateViewModel +import org.meshtastic.feature.connections.filterLiveUpdateCandidates + +/** + * The Live Update row and its chooser, on platforms that have Live Updates. Shown while connected, and while one runs + * whatever the connection, so a running Live Update can always be stopped. + */ +@Composable +fun LiveUpdateSection(connected: Boolean, modifier: Modifier = Modifier) { + val support = rememberLiveUpdateSupport() + if (!support.available) return + val viewModel: LiveUpdateViewModel = koinViewModel() + val mode by viewModel.mode.collectAsStateWithLifecycle() + val trackedNode by viewModel.trackedNode.collectAsStateWithLifecycle() + val candidates by viewModel.candidates.collectAsStateWithLifecycle() + var showChooser by rememberSaveable { mutableStateOf(false) } + + if (!connected && mode == LiveUpdateMode.Off) return + LiveUpdateCard( + mode = mode, + trackedNode = trackedNode, + turnedOffInSettings = support.turnedOff, + onClick = { showChooser = true }, + onOpenSettings = support.openSettings, + modifier = modifier, + ) + if (showChooser) { + LiveUpdateChooserSheet( + mode = mode, + candidates = candidates, + onStart = { + viewModel.setMode(it) + showChooser = false + }, + onStop = { + viewModel.setMode(LiveUpdateMode.Off) + showChooser = false + }, + onDismiss = { showChooser = false }, + ) + } +} + +/** The Connections row that shows what the Live Update is doing and opens the chooser. */ +@Composable +fun LiveUpdateCard( + mode: LiveUpdateMode, + trackedNode: Node?, + turnedOffInSettings: Boolean, + onClick: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + val summary = + when (mode) { + LiveUpdateMode.Off -> stringResource(Res.string.live_update_off_summary) + + LiveUpdateMode.MeshStatus -> stringResource(Res.string.live_update_mesh_status) + + is LiveUpdateMode.TrackNode -> + stringResource( + Res.string.live_update_tracking, + trackedNode?.displayName() ?: NodeAddress.numToDefaultId(mode.nodeNum), + ) + } + Card(modifier = modifier.fillMaxWidth()) { + ListItem( + text = stringResource(Res.string.live_update), + supportingText = summary, + leadingIcon = MeshtasticIcons.Notifications, + onClick = onClick, + ) + if (turnedOffInSettings && mode != LiveUpdateMode.Off) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.live_update_promotion_off), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onOpenSettings) { Text(stringResource(Res.string.open_settings)) } + } + } + } +} + +private enum class LiveUpdateChoice { + MeshStatus, + TrackNode, +} + +/** + * Picks what the Live Update shows: Mesh Status, or one node from a searchable list. [candidates] arrive already + * ordered, favorites first. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun LiveUpdateChooserSheet( + mode: LiveUpdateMode, + candidates: List, + onStart: (LiveUpdateMode) -> Unit, + onStop: () -> Unit, + onDismiss: () -> Unit, +) { + var choice by rememberSaveable { + mutableStateOf( + if (mode is LiveUpdateMode.TrackNode) LiveUpdateChoice.TrackNode else LiveUpdateChoice.MeshStatus, + ) + } + var selectedNum by rememberSaveable { mutableStateOf((mode as? LiveUpdateMode.TrackNode)?.nodeNum) } + var query by rememberSaveable { mutableStateOf("") } + val selection = + when (choice) { + LiveUpdateChoice.MeshStatus -> LiveUpdateMode.MeshStatus + LiveUpdateChoice.TrackNode -> selectedNum?.let { LiveUpdateMode.TrackNode(it) } + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = stringResource(Res.string.live_update), style = MaterialTheme.typography.titleLarge) + Column(modifier = Modifier.selectableGroup()) { + ChoiceRow( + selected = choice == LiveUpdateChoice.MeshStatus, + title = stringResource(Res.string.live_update_mesh_status), + summary = stringResource(Res.string.live_update_mesh_status_summary), + onSelect = { choice = LiveUpdateChoice.MeshStatus }, + ) + ChoiceRow( + selected = choice == LiveUpdateChoice.TrackNode, + title = stringResource(Res.string.live_update_track_node), + summary = stringResource(Res.string.live_update_track_node_summary), + onSelect = { choice = LiveUpdateChoice.TrackNode }, + ) + } + if (choice == LiveUpdateChoice.TrackNode) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { Icon(MeshtasticIcons.Search, contentDescription = null) }, + placeholder = { Text(stringResource(Res.string.live_update_search_nodes)) }, + ) + val shown = filterLiveUpdateCandidates(candidates, query) + if (shown.isEmpty()) { + Text( + text = stringResource(Res.string.live_update_no_nodes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp).selectableGroup()) { + items(shown, key = { it.num }) { node -> + NodeChoiceRow( + node = node, + selected = node.num == selectedNum, + onSelect = { selectedNum = node.num }, + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + if (mode != LiveUpdateMode.Off) { + TextButton(onClick = onStop) { Text(stringResource(Res.string.live_update_stop)) } + } + Button( + onClick = { selection?.let(onStart) }, + enabled = selection != null && selection != mode, + ) { + val label = + if (mode == LiveUpdateMode.Off) Res.string.live_update_start else Res.string.live_update_switch + Text(stringResource(label)) + } + } + } + } +} + +@Composable +private fun ChoiceRow(selected: Boolean, title: String, summary: String, onSelect: () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth() + .selectable(selected = selected, role = Role.RadioButton, onClick = onSelect) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null) + Column(modifier = Modifier.padding(start = 16.dp)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun NodeChoiceRow(node: Node, selected: Boolean, onSelect: () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth() + .selectable(selected = selected, role = Role.RadioButton, onClick = onSelect) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null) + Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + Text(text = node.displayName(), style = MaterialTheme.typography.bodyLarge) + Text( + text = formatAgo(node.lastHeard), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (node.isFavorite) { + Icon( + imageVector = MeshtasticIcons.Favorite, + contentDescription = stringResource(Res.string.favorite), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +private fun Node.displayName(): String = user.long_name.ifBlank { user.short_name }.ifBlank { user.id } diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/LiveUpdateCandidatesTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/LiveUpdateCandidatesTest.kt new file mode 100644 index 00000000000..4e0a73620c5 --- /dev/null +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/LiveUpdateCandidatesTest.kt @@ -0,0 +1,73 @@ +/* + * 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.connections + +import org.meshtastic.core.model.Node +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals + +class LiveUpdateCandidatesTest { + private fun node(num: Int, lastHeard: Int, favorite: Boolean = false, ignored: Boolean = false, name: String = "") = + Node( + num = num, + lastHeard = lastHeard, + isFavorite = favorite, + isIgnored = ignored, + user = + User.Builder() + .also { wb -> + wb.id = "!" + num.toString(16).padStart(8, '0') + wb.long_name = name + wb.short_name = name.take(4) + } + .build(), + ) + + @Test + fun `favorites come first and then the most recently heard`() { + val stale = node(1, lastHeard = 100) + val recent = node(2, lastHeard = 300) + val staleFavorite = node(3, lastHeard = 50, favorite = true) + val recentFavorite = node(4, lastHeard = 200, favorite = true) + + val ordered = liveUpdateCandidates(listOf(stale, recent, staleFavorite, recentFavorite), ourNodeNum = null) + + assertEquals(listOf(4, 3, 2, 1), ordered.map { it.num }) + } + + @Test + fun `our own node and ignored nodes are not offered`() { + val ours = node(1, lastHeard = 500) + val ignored = node(2, lastHeard = 400, ignored = true) + val other = node(3, lastHeard = 300) + + assertEquals(listOf(3), liveUpdateCandidates(listOf(ours, ignored, other), ourNodeNum = 1).map { it.num }) + } + + @Test + fun `search matches long name short name and id ignoring case`() { + val base = node(0x1a, lastHeard = 1, name = "Base Camp") + val ridge = node(0x2b, lastHeard = 1, name = "Ridge") + val candidates = listOf(base, ridge) + + assertEquals(listOf(base), filterLiveUpdateCandidates(candidates, "camp")) + assertEquals(listOf(ridge), filterLiveUpdateCandidates(candidates, "RIDG")) + assertEquals(listOf(ridge), filterLiveUpdateCandidates(candidates, "0000002b")) + assertEquals(candidates, filterLiveUpdateCandidates(candidates, " ")) + } +} diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml index 7db65a1758b..723bd43b531 100644 --- a/feature/node/detekt-baseline.xml +++ b/feature/node/detekt-baseline.xml @@ -112,6 +112,6 @@ UseOrEmpty:PowerMetrics.kt:state.node?.user?.long_name ?: "" UseOrEmpty:SignalMetrics.kt:state.node?.user?.long_name ?: "" UseOrEmpty:TracerouteLog.kt:state.node?.user?.long_name ?: "" - ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, ) + ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, trackedInLiveUpdate = trackedInLiveUpdate, onTrackInLiveUpdate = onTrackInLiveUpdate, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index c1932b3d76d..c2a4393e96d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.resources.actions import org.meshtastic.core.resources.direct_message import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.ignore +import org.meshtastic.core.resources.live_update_track_in import org.meshtastic.core.resources.mute_notifications import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.share_contact @@ -54,6 +55,7 @@ import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Message import org.meshtastic.core.ui.icon.NotFavorite +import org.meshtastic.core.ui.icon.Notifications import org.meshtastic.core.ui.icon.QrCode2 import org.meshtastic.core.ui.icon.VolumeMute import org.meshtastic.core.ui.icon.VolumeOff @@ -77,6 +79,7 @@ fun DeviceActions( isLocal: Boolean = false, hasConversation: Boolean = false, airQualityHistory: List = emptyList(), + trackedInLiveUpdate: Boolean? = null, ) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { SectionCard(title = Res.string.actions) { @@ -84,7 +87,7 @@ fun DeviceActions( if (!isLocal) { SectionDivider(Modifier.padding(vertical = 8.dp)) - ManagementActions(node = node, onAction = onAction) + ManagementActions(node = node, trackedInLiveUpdate = trackedInLiveUpdate, onAction = onAction) } } @@ -163,8 +166,18 @@ private fun PrimaryActionsRow( } @Composable -private fun ManagementActions(node: Node, onAction: (NodeDetailAction) -> Unit) { +private fun ManagementActions(node: Node, trackedInLiveUpdate: Boolean?, onAction: (NodeDetailAction) -> Unit) { Column { + // Null where Live Updates do not exist; an ignored node cannot be tracked. + if (trackedInLiveUpdate != null && !node.isIgnored) { + SwitchListItem( + text = stringResource(Res.string.live_update_track_in), + leadingIcon = MeshtasticIcons.Notifications, + checked = trackedInLiveUpdate, + onClick = { onAction(NodeDetailAction.TrackInLiveUpdate(node.num, track = !trackedInLiveUpdate)) }, + ) + } + SwitchListItem( text = stringResource(Res.string.ignore), leadingIcon = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index fd0f9d897df..7216222cde6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -59,6 +59,7 @@ fun NodeDetailContent( onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), + trackedInLiveUpdate: Boolean? = null, ) { Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent -> if (isNodePresent && uiState.node != null) { @@ -70,6 +71,7 @@ fun NodeDetailContent( onAction = onAction, onFirmwareSelect = onFirmwareSelect, onSaveNotes = onSaveNotes, + trackedInLiveUpdate = trackedInLiveUpdate, ) } else { val loadingDescription = stringResource(Res.string.loading) @@ -94,6 +96,7 @@ fun NodeDetailList( onFirmwareSelect: (FirmwareRelease) -> Unit, onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier, + trackedInLiveUpdate: Boolean? = null, ) { LazyColumn( modifier = modifier.fillMaxSize(), @@ -130,6 +133,7 @@ fun NodeDetailList( isLocal = uiState.metricsState.isLocal, hasConversation = uiState.hasConversation, airQualityHistory = uiState.metricsState.airQualityMetrics, + trackedInLiveUpdate = trackedInLiveUpdate, ) } item { NotesSection(node = node, onSaveNotes = onSaveNotes) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 760f0325706..0ac36c2fc69 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -40,11 +40,13 @@ import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.model.FirmwareRelease import org.meshtastic.core.model.Node import org.meshtastic.core.navigation.Route +import org.meshtastic.core.repository.LiveUpdateMode import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.details import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.ShareContactDialog import org.meshtastic.core.ui.util.ActiveWhileStarted +import org.meshtastic.core.ui.util.rememberLiveUpdateSupport import org.meshtastic.feature.node.compass.CompassUiState import org.meshtastic.feature.node.compass.CompassViewModel import org.meshtastic.feature.node.component.CompassSheetContent @@ -70,11 +72,20 @@ fun NodeDetailScreen( onNavigate: (Route) -> Unit = {}, onNavigateUp: () -> Unit = {}, compassViewModel: CompassViewModel? = null, + liveUpdateViewModel: NodeLiveUpdateViewModel? = null, ) { SideEffect(nodeId) { viewModel.start(nodeId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentOnNavigate by rememberUpdatedState(onNavigate) LaunchedEffect(viewModel) { viewModel.navigationEvents.collect { currentOnNavigate(it) } } + val liveUpdateAvailable = rememberLiveUpdateSupport().available && liveUpdateViewModel != null + val liveUpdateMode by + liveUpdateViewModel?.mode?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(LiveUpdateMode.Off) } + val trackedInLiveUpdate = + uiState.node?.takeIf { liveUpdateAvailable }?.let { liveUpdateMode == LiveUpdateMode.TrackNode(it.num) } + val onTrackInLiveUpdate: (Int, Boolean) -> Unit = { nodeNum, track -> + liveUpdateViewModel?.setTracking(nodeNum, track) + } NodeDetailScaffold( modifier = modifier, uiState = uiState, @@ -83,6 +94,8 @@ fun NodeDetailScreen( onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, + trackedInLiveUpdate = trackedInLiveUpdate, + onTrackInLiveUpdate = onTrackInLiveUpdate, ) } @@ -96,6 +109,8 @@ private fun NodeDetailScaffold( onNavigate: (Route) -> Unit, onNavigateUp: () -> Unit, compassViewModel: CompassViewModel? = null, + trackedInLiveUpdate: Boolean? = null, + onTrackInLiveUpdate: (nodeNum: Int, track: Boolean) -> Unit = { _, _ -> }, ) { var activeOverlay by remember { mutableStateOf(null) } // Saved, unlike the overlays: a rotation must not close the model, which the user may be turning the phone for. @@ -125,10 +140,13 @@ private fun NodeDetailScaffold( NodeDetailContent( uiState = uiState, listState = listState, + trackedInLiveUpdate = trackedInLiveUpdate, onAction = { action -> when (action) { is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact + is NodeDetailAction.TrackInLiveUpdate -> onTrackInLiveUpdate(action.nodeNum, action.track) + is NodeDetailAction.OpenCompass -> activeOverlay = NodeDetailOverlay.Compass(action.node.num, action.displayUnits) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeLiveUpdateViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeLiveUpdateViewModel.kt new file mode 100644 index 00000000000..a7aa52fa9a5 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeLiveUpdateViewModel.kt @@ -0,0 +1,36 @@ +/* + * 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.node.detail + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.repository.LiveUpdateMode +import org.meshtastic.core.repository.LiveUpdatePrefs + +/** The node detail shortcut into the Live Update: track this node, or stop tracking it. */ +@KoinViewModel +class NodeLiveUpdateViewModel(private val prefs: LiveUpdatePrefs) : ViewModel() { + val mode: StateFlow = prefs.mode + + fun setTracking(nodeNum: Int, track: Boolean) { + when { + track -> prefs.setMode(LiveUpdateMode.TrackNode(nodeNum)) + prefs.mode.value == LiveUpdateMode.TrackNode(nodeNum) -> prefs.setMode(LiveUpdateMode.Off) + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt index ffd6f56366e..4d10f8a3045 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt @@ -40,6 +40,9 @@ sealed interface NodeDetailAction { /** Open the real-size 3D model of the node's hardware. */ data class OpenDeviceModel(val deviceHardware: DeviceHardware) : NodeDetailAction + /** Start or stop tracking this node in the Live Update. */ + data class TrackInLiveUpdate(val nodeNum: Int, val track: Boolean) : NodeDetailAction + // Opens the compass sheet scoped to a target node and the user’s preferred units. data class OpenCompass(val node: Node, val displayUnits: MeasurementSystem) : NodeDetailAction } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 11eba202cde..a2bbf33d633 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -107,6 +107,7 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) nodeId = destNum, viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, + liveUpdateViewModel = koinViewModel(), navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) }, onNavigate = { route -> backStack.add(route) }, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, From 0e458fa4df7243a5592fd9d9f08b7e7c45e3e2b0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:49:27 -0500 Subject: [PATCH 2/2] fix(notifications): drop the Live Update chip for a node heard under a minute ago It read `0m`. --- .../meshtastic/core/service/MeshNotificationManagerImpl.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 29450d7cf2c..1362b1a680a 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 @@ -980,7 +980,9 @@ class MeshNotificationManagerImpl( } val chip = battery?.takeIf { it <= MAX_BATTERY_LEVEL }?.let { MetricFormatter.percent(it) } - ?: heardSecondsAgo?.let { formatDurationSuspend(it, largestUnit(it)) } + ?: heardSecondsAgo + ?.takeIf { it >= SECONDS_PER_MINUTE } + ?.let { formatDurationSuspend(it, largestUnit(it)) } return createLiveUpdateNotification( title = node.user.long_name.ifBlank { node.user.short_name }.ifBlank { NodeAddress.numToDefaultId(node.num) },