diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 7b23affd4a..de81176652 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1040,15 +1040,28 @@ mesh_map_location_description
### MESHTASTIC ###
meshtastic
meshtastic_alerts_notifications
+meshtastic_alerts_notifications_description
meshtastic_app_name
meshtastic_broadcast_notifications
+meshtastic_broadcast_notifications_description
+meshtastic_client_notifications
+meshtastic_client_notifications_description
+meshtastic_device_status_notifications
+meshtastic_device_status_notifications_description
meshtastic_low_battery_notifications
+meshtastic_low_battery_notifications_description
meshtastic_low_battery_temporary_remote_notifications
+meshtastic_low_battery_temporary_remote_notifications_description
meshtastic_mesh_beacon_notifications
+meshtastic_mesh_beacon_notifications_description
meshtastic_messages_notifications
+meshtastic_messages_notifications_description
meshtastic_new_nodes_notifications
+meshtastic_new_nodes_notifications_description
meshtastic_service_notifications
+meshtastic_service_notifications_description
meshtastic_waypoints_notifications
+meshtastic_waypoints_notifications_description
### MESSAGE ###
message
message_delivery_status
@@ -1267,10 +1280,13 @@ not_connected
not_now
note
notes
+### NOTIFICATION ###
+notification_group_mesh
notification_permission_blocked_notice
notification_permission_denied_notice
notification_permission_rationale
notification_permissions_description
+notification_reaction_to
### NOTIFICATIONS ###
notifications_for_channel_and_direct_messages
notifications_for_low_battery_alerts
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 0eea31e77f..0a1e965aaf 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -42,6 +42,8 @@
+
+
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
deleted file mode 100644
index 99120129d0..0000000000
--- a/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * 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.app.service
-
-import dev.mokkery.MockMode
-import dev.mokkery.mock
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.repository.MeshNotificationManager
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.proto.ClientNotification
-import org.meshtastic.proto.Telemetry
-
-class Fakes {
- val service: RadioInterfaceService = mock(MockMode.autofill)
-}
-
-class FakeMeshNotificationManager : MeshNotificationManager {
- override fun clearNotifications() {}
-
- override fun initChannels() {}
-
- override fun updateServiceStateNotification(
- state: org.meshtastic.core.model.ConnectionState,
- telemetry: Telemetry?,
- ) {}
-
- override suspend fun updateMessageNotification(
- contactKey: String,
- name: String,
- message: String,
- isBroadcast: Boolean,
- channelName: String?,
- isSilent: Boolean,
- ) {}
-
- override suspend fun updateWaypointNotification(
- contactKey: String,
- name: String,
- message: String,
- waypointId: Int,
- isSilent: Boolean,
- ) {}
-
- override suspend fun updateReactionNotification(
- contactKey: String,
- name: String,
- emoji: String,
- isBroadcast: Boolean,
- channelName: String?,
- isSilent: Boolean,
- ) {}
-
- override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
-
- override fun showNewNodeSeenNotification(node: Node) {}
-
- override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
-
- override fun showClientNotification(clientNotification: ClientNotification) {}
-
- override suspend fun cancelMessageNotification(contactKey: String) {}
-
- override fun cancelLowBatteryNotification(node: Node) {}
-
- override fun clearClientNotification(notification: ClientNotification) {}
-}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
index 13cfbfa1c2..0253f8f906 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
@@ -27,15 +27,14 @@ import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.XModemManager
-import org.meshtastic.core.repository.notificationId
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.duplicated_public_key_title
@@ -57,7 +56,7 @@ class FromRadioPacketHandlerImpl(
private val xmodemManager: Lazy,
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
- private val notificationManager: NotificationManager,
+ private val serviceNotifications: MeshNotificationManager,
private val lockdownCoordinator: LockdownCoordinator,
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
private val radioInterfaceService: RadioInterfaceService,
@@ -156,7 +155,7 @@ class FromRadioPacketHandlerImpl(
private fun handleClientNotification(cn: ClientNotification, session: RadioSessionContext) {
val admitted =
radioInterfaceService.runIfSessionActive(session) {
- if (!notificationManager.suppressClientNotificationModal(cn)) {
+ if (!serviceNotifications.suppressClientNotificationModal(cn)) {
serviceStateWriter.setClientNotification(cn)
}
}
@@ -212,15 +211,6 @@ class FromRadioPacketHandlerImpl(
else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info)
}
- notificationManager.dispatchClientNotification(
- Notification(
- title = title,
- type = type,
- message = cn.message,
- category = Notification.Category.Alert,
- id = cn.notificationId(),
- ),
- cn,
- )
+ serviceNotifications.showClientNotification(cn, title, type)
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index ecc77d111b..a939562013 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -57,8 +57,6 @@ import org.meshtastic.core.repository.MessageAnnouncement
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
@@ -73,8 +71,6 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getStringSuspend
-import org.meshtastic.core.resources.mesh_beacon_notification_body
-import org.meshtastic.core.resources.mesh_beacon_notification_title
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.MeshBeacon
@@ -103,7 +99,6 @@ class MeshDataHandlerImpl(
private val packetHandler: PacketHandler,
private val serviceStateWriter: ServiceStateWriter,
private val packetRepository: Lazy,
- private val notificationManager: NotificationManager,
private val serviceNotifications: MeshNotificationManager,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
@@ -260,15 +255,7 @@ class MeshDataHandlerImpl(
// does not warrant a notification.
val channelSet = radioConfigRepository.channelSetFlow.first()
if (beacon.isAlreadyJoined(channelSet.lora_config, channelSet.settings)) return@launchSessionWork
- notificationManager.dispatch(
- Notification(
- title = getStringSuspend(Res.string.mesh_beacon_notification_title),
- message = offer.message.ifBlank { getStringSuspend(Res.string.mesh_beacon_notification_body) },
- category = Notification.Category.MeshBeacon,
- // Literal URI avoids a core:navigation module dep (see NodeManagerImpl).
- deepLinkUri = "meshtastic://meshtastic/discovery",
- ),
- )
+ serviceNotifications.showMeshBeaconNotification(offer)
}
}
}
@@ -554,13 +541,10 @@ class MeshDataHandlerImpl(
// conversation on screen — only mute silences it.
dataPacket.dataType == PortNum.ALERT_APP.value ->
if (!muted) {
- notificationManager.dispatch(
- Notification(
- title = getSenderName(dataPacket),
- message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
- category = Notification.Category.Alert,
- contactKey = contactKey,
- ),
+ serviceNotifications.showAlertNotification(
+ contactKey,
+ getSenderName(dataPacket),
+ dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
)
}
@@ -578,6 +562,11 @@ class MeshDataHandlerImpl(
}
}
+ /** Test seam over the waypoint notification text; compose-resources cannot load in the plain-JVM tests. */
+ internal var waypointMessageFormatter: suspend (String) -> String = { waypointName ->
+ getStringSuspend(Res.string.waypoint_received, waypointName)
+ }
+
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.source is NodeAddress.Local) {
val myId = nodeManager.getMyId()
@@ -609,15 +598,13 @@ class MeshDataHandlerImpl(
}
PortNum.WAYPOINT_APP.value -> {
- val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
- notificationManager.dispatch(
- Notification(
- title = getSenderName(dataPacket),
- message = message,
- category = Notification.Category.Message,
- contactKey = contactKey,
- isSilent = isSilent,
- ),
+ val waypoint = dataPacket.waypoint!!
+ serviceNotifications.updateWaypointNotification(
+ contactKey,
+ getSenderName(dataPacket),
+ waypointMessageFormatter(waypoint.name),
+ waypoint.id,
+ isSilent,
)
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index 06f89908f2..1d3b8a402d 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -39,10 +39,9 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.core.repository.ConnectionIdentity
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.resources.Res
@@ -85,7 +84,7 @@ private val DEFAULT_NODE_NAME_REGEX = Regex("^Meshtastic [0-9a-fA-F]{4}$")
@Single(binds = [NodeManager::class, NodeIdLookup::class])
class NodeManagerImpl(
private val nodeRepository: NodeRepository,
- private val notificationManager: NotificationManager,
+ private val serviceNotifications: MeshNotificationManager,
private val radioInterfaceService: RadioInterfaceService,
private val scope: ServiceScope,
) : NodeManager {
@@ -564,7 +563,7 @@ class NodeManagerImpl(
// Commit retirement first. A dispatch already in progress will fail its final state revalidation; one that
// completed before the commit is removed by this cancellation. Side effects run once, outside the CAS loop.
removedNums.forEach { num ->
- notificationManager.cancel(num)
+ serviceNotifications.cancelNewNodeNotification(num)
val keyDescription =
committedHints[num]?.let(::publicKeyLogFingerprint)
?: if (num in committedPresentNums) "none" else "absent"
@@ -1213,15 +1212,7 @@ class NodeManagerImpl(
return@launchSessionWork
}
Logger.d { "[NodeIdentity] notification-dispatch num=${node.num}" }
- notificationManager.dispatch(
- Notification(
- title = title,
- message = node.user.long_name,
- category = Notification.Category.NodeEvent,
- id = node.num,
- deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}",
- ),
- )
+ serviceNotifications.showNewNodeSeenNotification(node, title)
}
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
index 0f35f6aef0..76a86b822e 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
@@ -27,16 +27,11 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.TelemetryPacketHandler
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getStringSuspend
-import org.meshtastic.core.resources.low_battery_message
-import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Telemetry
import kotlin.time.Duration.Companion.milliseconds
@@ -49,7 +44,7 @@ import kotlin.time.Duration.Companion.milliseconds
class TelemetryPacketHandlerImpl(
private val nodeManager: NodeManager,
private val connectionManager: Lazy,
- private val notificationManager: NotificationManager,
+ private val serviceNotifications: MeshNotificationManager,
private val radioInterfaceService: RadioInterfaceService,
private val scope: ServiceScope,
) : TelemetryPacketHandler {
@@ -97,45 +92,27 @@ class TelemetryPacketHandlerImpl(
val updatedNode = nodeManager.nodeDBbyNodeNum[fromNum] ?: return
if (fromNum != myNodeNum && !updatedNode.isFavorite) return
- if (
- (metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
- (metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
- ) {
+ // A reading without a level says nothing about the battery, so it neither warns nor clears.
+ val batteryLevel = metrics.battery_level ?: return
+ val hasBattery = (metrics.voltage ?: 0f) > 0f
+ if (hasBattery && batteryLevel <= BATTERY_PERCENT_LOW_THRESHOLD) {
radioInterfaceService.launchSessionWork(scope, session) {
- if (shouldBatteryNotificationShow(fromNum, telemetry, myNodeNum)) {
- notificationManager.dispatch(
- Notification(
- title = getStringSuspend(Res.string.low_battery_title, updatedNode.user.short_name),
- message =
- getStringSuspend(
- Res.string.low_battery_message,
- updatedNode.user.long_name,
- updatedNode.deviceMetrics.battery_level ?: 0,
- ),
- category = Notification.Category.Battery,
- ),
- )
+ val firstLowReading = batteryMutex.withLock { notifiedNodes.add(fromNum) }
+ if (firstLowReading) {
+ serviceNotifications.showLowBatteryNotification(updatedNode, isRemote)
+ } else {
+ serviceNotifications.updateLowBatteryNotification(updatedNode, isRemote)
}
}
} else {
radioInterfaceService.launchSessionWork(scope, session) {
batteryMutex.withLock { notifiedNodes.remove(fromNum) }
- notificationManager.cancel(updatedNode.num)
+ serviceNotifications.cancelLowBatteryNotification(updatedNode)
}
}
}
- @Suppress("UnusedParameter")
- private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
- batteryMutex.withLock {
- if (fromNum in notifiedNodes) return false
- notifiedNodes.add(fromNum)
- }
- return true
- }
-
companion object {
- private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
}
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
index 9b6e62cf5b..18644aade4 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
@@ -30,8 +30,8 @@ import org.meshtastic.core.model.util.isOtaStatusNotification
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MqttManager
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
@@ -64,7 +64,7 @@ class FromRadioPacketHandlerImplTest {
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val mqttManager: MqttManager = mock(MockMode.autofill)
private val packetHandler: PacketHandler = mock(MockMode.autofill)
- private val notificationManager: NotificationManager = mock(MockMode.autofill)
+ private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val xmodemManager: XModemManager = mock(MockMode.autofill)
@@ -107,7 +107,7 @@ class FromRadioPacketHandlerImplTest {
lazy { xmodemManager },
mqttManager,
packetHandler,
- notificationManager,
+ serviceNotifications,
lockdownCoordinator,
firmwareUpdateStatusRepository,
radioInterfaceService,
@@ -328,7 +328,7 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `platform-suppressed client notification skips modal state but preserves system delivery`() {
val notification = protectedPositionAdvisory(replyId = 100, time = 1_000)
- every { notificationManager.suppressClientNotificationModal(notification) } returns true
+ every { serviceNotifications.suppressClientNotificationModal(notification) } returns true
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
@@ -339,7 +339,7 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `default platform policy preserves modal and system delivery for exact advisory`() {
val notification = protectedPositionAdvisory(replyId = 200, time = 2_000)
- every { notificationManager.suppressClientNotificationModal(notification) } returns false
+ every { serviceNotifications.suppressClientNotificationModal(notification) } returns false
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
@@ -355,7 +355,7 @@ class FromRadioPacketHandlerImplTest {
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
verify(mode = VerifyMode.exactly(0)) { serviceRepository.setClientNotification(any()) }
- verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatchClientNotification(any(), any()) }
+ verifySuspend(mode = VerifyMode.exactly(0)) { serviceNotifications.showClientNotification(any(), any(), any()) }
}
@Test
@@ -366,7 +366,7 @@ class FromRadioPacketHandlerImplTest {
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
verify { serviceRepository.setClientNotification(notification) }
- verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatchClientNotification(any(), any()) }
+ verifySuspend(mode = VerifyMode.exactly(0)) { serviceNotifications.showClientNotification(any(), any(), any()) }
}
@Test
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
index 0228b2192e..2dbd39b23d 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
@@ -54,7 +54,6 @@ import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
@@ -96,7 +95,6 @@ class MeshDataHandlerTest {
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
- private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
@@ -156,7 +154,6 @@ class MeshDataHandlerTest {
packetHandler = packetHandler,
serviceStateWriter = serviceRepository,
packetRepository = lazy { packetRepository },
- notificationManager = notificationManager,
serviceNotifications = serviceNotifications,
analytics = analytics,
dataMapper = dataMapper,
@@ -684,7 +681,47 @@ class MeshDataHandlerTest {
// Still stored, so it can reappear in the invitations list if the user later deletes the channel.
assertEquals(1, meshBeaconRepository.offers.value.size)
- verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
+ serviceNotifications.showMeshBeaconNotification(any())
+ }
+ }
+
+ @Test
+ fun `mesh beacon offering a channel the radio lacks notifies with the offer`() = testScope.runTest {
+ every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet.Builder().build())
+ val beacon =
+ MeshBeacon.Builder()
+ .also { wb ->
+ wb.message = "Join us"
+ wb.offer_channel = ChannelSettings.Builder().also { wb -> wb.name = "PartyNet" }.build()
+ }
+ .build()
+ val packet =
+ MeshPacket.Builder()
+ .also { wb ->
+ wb.from = 456
+ wb.decoded =
+ Data.Builder()
+ .also { wb ->
+ wb.portnum = PortNum.MESH_BEACON_APP
+ wb.payload = beacon.encode().toByteString()
+ }
+ .build()
+ }
+ .build()
+ every { dataMapper.toDataPacket(packet) } returns
+ DataPacket(
+ from = "!remote",
+ bytes = beacon.encode().toByteString(),
+ dataType = PortNum.MESH_BEACON_APP.value,
+ )
+
+ handler.handleReceivedData(packet, 123)
+ advanceUntilIdle()
+
+ verifySuspend {
+ serviceNotifications.showMeshBeaconNotification(meshBeaconRepository.offers.value.single())
+ }
}
// --- Store-and-Forward handling ---
@@ -1987,6 +2024,78 @@ class MeshDataHandlerTest {
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
}
+ @Test
+ fun `received waypoint notifies on the waypoint path`() = testScope.runTest {
+ handler.waypointMessageFormatter = { name -> "Waypoint received: $name" }
+ every { packetRepository.getWaypoints() } returns flowOf(emptyList())
+ every { nodeManager.getNodeById(any()) } returns
+ Node(num = 999, user = User.Builder().also { wb -> wb.long_name = "Hawk Ridge" }.build())
+ val packet =
+ waypointPacket(
+ txId = 506,
+ from = 999,
+ waypoint =
+ Waypoint.Builder()
+ .also { wb ->
+ wb.id = 42
+ wb.name = "Camp"
+ wb.expire = Int.MAX_VALUE
+ }
+ .build(),
+ )
+ stubWaypointPersistDependencies(506)
+
+ handler.handleReceivedData(packet, 123)
+ advanceUntilIdle()
+
+ verifySuspend {
+ serviceNotifications.updateWaypointNotification(
+ any(),
+ "Hawk Ridge",
+ "Waypoint received: Camp",
+ 42,
+ false,
+ )
+ }
+ }
+
+ @Test
+ fun `critical alert notifies on the alert path with its conversation`() = testScope.runTest {
+ val payload = "Fire at camp".encodeToByteArray().toByteString()
+ val packet =
+ MeshPacket.Builder()
+ .also { wb ->
+ wb.id = 507
+ wb.from = 456
+ wb.decoded =
+ Data.Builder()
+ .also { wb ->
+ wb.portnum = PortNum.ALERT_APP
+ wb.payload = payload
+ }
+ .build()
+ }
+ .build()
+ every { dataMapper.toDataPacket(packet) } returns
+ DataPacket(
+ id = 507,
+ from = "!remote",
+ to = NodeAddress.ID_BROADCAST,
+ bytes = payload,
+ dataType = PortNum.ALERT_APP.value,
+ )
+ everySuspend { packetRepository.findPacketsWithId(507) } returns emptyList()
+ everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
+ every { messageFilter.shouldFilter(any(), any()) } returns false
+ every { nodeManager.getNodeById("!remote") } returns
+ Node(num = 456, user = User.Builder().also { wb -> wb.long_name = "Remote User" }.build())
+
+ handler.handleReceivedData(packet, 123)
+ advanceUntilIdle()
+
+ verifySuspend { serviceNotifications.showAlertNotification("0^all", "Remote User", "Fire at camp") }
+ }
+
@Test
fun `waypoint locked to someone other than the sender is dropped`() = testScope.runTest {
// Pre-existing inbound-payload rule: a node can only lock a waypoint to itself. Nothing stored here — the
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt
index 51222559be..5b898543fb 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt
@@ -29,8 +29,8 @@ import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.di.asServiceScope
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.repository.ConnectionIdentity
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -49,7 +49,7 @@ import kotlin.test.assertTrue
class NodeManagerConnectionIdentityTest {
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
- private val notificationManager: NotificationManager = mock(MockMode.autofill)
+ private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val testScope = TestScope()
@@ -59,7 +59,7 @@ class NodeManagerConnectionIdentityTest {
fun setUp() {
everySuspend { nodeRepository.getNodeDbSnapshot() } returns emptyMap()
nodeManager =
- NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope.asServiceScope())
+ NodeManagerImpl(nodeRepository, serviceNotifications, radioInterfaceService, testScope.asServiceScope())
}
@Test
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
index 741928bf98..739d01623f 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
@@ -43,9 +43,8 @@ import org.meshtastic.core.common.util.crc32
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.proto.DeviceMetadata
@@ -70,7 +69,7 @@ import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
- private val notificationManager: NotificationManager = mock(MockMode.autofill)
+ private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val testScope = TestScope()
@@ -79,7 +78,7 @@ class NodeManagerImplTest {
@BeforeTest
fun setUp() {
nodeManager =
- NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope.asServiceScope())
+ NodeManagerImpl(nodeRepository, serviceNotifications, radioInterfaceService, testScope.asServiceScope())
// Override the compose-resources formatter so notification dispatch is deterministic in the
// plain-JVM test env (getStringSuspend does not resolve here). Tests that assert "no dispatch"
// still hold: the override only changes the title, not whether dispatch fires.
@@ -1142,7 +1141,7 @@ class NodeManagerImplTest {
assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
verifySuspend(mode = VerifyMode.not) { nodeRepository.insertMetadata(any(), any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -1194,7 +1193,7 @@ class NodeManagerImplTest {
assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
assertEquals("Canonical", nodeManager.nodeDBbyNodeNum[canonicalNum]?.user?.long_name)
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -1228,7 +1227,7 @@ class NodeManagerImplTest {
assertEquals(replacementKey, replacement.publicKey)
assertEquals("Replacement", replacement.user.long_name)
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.exactly(1)) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.exactly(1)) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -1266,7 +1265,7 @@ class NodeManagerImplTest {
assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -1322,7 +1321,7 @@ class NodeManagerImplTest {
assertEquals(validPk, canonical.publicKey)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 2. Placeholder duplicate
@@ -1384,7 +1383,7 @@ class NodeManagerImplTest {
assertEquals("Migrated", canonical.user.long_name)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 3. Different-key conflict
@@ -1420,7 +1419,7 @@ class NodeManagerImplTest {
assertEquals("Other", otherNode.user.long_name)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 4. Multiple same-key matches
@@ -1454,7 +1453,7 @@ class NodeManagerImplTest {
assertEquals("Incoming", nodeManager.nodeDBbyNodeNum[fromNum]?.user?.long_name)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -1484,7 +1483,7 @@ class NodeManagerImplTest {
assertEquals(validPk, incoming.publicKey)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -1540,7 +1539,7 @@ class NodeManagerImplTest {
assertEquals(456, incoming.position.longitude_i)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 5. Local authoritative renumber
@@ -1573,7 +1572,7 @@ class NodeManagerImplTest {
assertEquals(validPk, localNode.publicKey)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 6. New-node notification admission
@@ -1601,7 +1600,7 @@ class NodeManagerImplTest {
assertNotNull(result)
assertEquals("Replayed Node", result.user.long_name)
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -1631,7 +1630,7 @@ class NodeManagerImplTest {
assertEquals("Retried Baseline Node", nodeManager.nodeDBbyNodeNum[replayedNodeNum]?.user?.long_name)
assertEquals(1234, nodeManager.myNodeNum.value)
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
assertEquals(2, reductionCount, "the forced CAS mismatch must execute the reducer twice")
}
@@ -1651,8 +1650,10 @@ class NodeManagerImplTest {
.build()
enableDbWrites()
- val captured = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(captured)) } returns true
+ val captured = mutableListOf()
+ val titles = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(captured), capture(titles)) } returns
+ Unit
nodeManager.handleReceivedUser(newNodeNum, newUser)
testScope.advanceUntilIdle()
@@ -1663,13 +1664,11 @@ class NodeManagerImplTest {
assertEquals(newPk, result.publicKey)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
- // Strengthen: capture the dispatched Notification and verify payload + routing fields, not just the call count.
+ // Strengthen: capture the posted node and title, not just the call count.
assertEquals(1, captured.size)
- val n = captured.first()
- assertEquals(newNodeNum, n.id)
- assertEquals("New Node", n.message)
- assertEquals(Notification.Category.NodeEvent, n.category)
- assertEquals("meshtastic://meshtastic/nodes/$newNodeNum", n.deepLinkUri)
+ assertEquals(newNodeNum, captured.single().num)
+ assertEquals("New Node", captured.single().user.long_name)
+ assertEquals("New node seen: NEW", titles.single())
}
// 7. Invalid / malformed keys (null, empty, ERROR) are treated as NoMatch
@@ -1852,7 +1851,7 @@ class NodeManagerImplTest {
testScope.advanceUntilIdle()
// No notification fired for any of the three outcomes.
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 11. byId consistency after stale removal — when stale and canonical share a user ID,
@@ -2443,17 +2442,7 @@ class NodeManagerImplTest {
nodeManager.handleReceivedUser(nodeNum, user)
testScope.advanceUntilIdle()
- verifySuspend {
- notificationManager.dispatch(
- Notification(
- title = "New node seen: TST",
- message = "Test User",
- category = Notification.Category.NodeEvent,
- id = nodeNum,
- deepLinkUri = "meshtastic://meshtastic/nodes/$nodeNum",
- ),
- )
- }
+ verifySuspend { serviceNotifications.showNewNodeSeenNotification(any(), "New node seen: TST") }
}
// 22. Incoming at the canonical num reconciles in memory only and preserves its placeholder history.
@@ -2516,7 +2505,7 @@ class NodeManagerImplTest {
assertEquals(4, canonical.channel)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 23. Incoming canonical absent from index entirely creates from packet, removes noncanonical, no notify.
@@ -2550,7 +2539,7 @@ class NodeManagerImplTest {
assertEquals(canonicalKey, canonical.publicKey)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 24. Neither num canonical preserves both with no side effects.
@@ -2590,7 +2579,7 @@ class NodeManagerImplTest {
assertEquals("Bravo", bravo.user.long_name)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 25. Different established valid key at the canonical num is preserved as a conflict.
@@ -2630,7 +2619,7 @@ class NodeManagerImplTest {
assertEquals(canonicalKey, atNoncanonical.publicKey)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
// 26. Local classification and the node index share one atomic CAS state.
@@ -2666,7 +2655,7 @@ class NodeManagerImplTest {
assertEquals(newLocalNum, nodeManager.myNodeNum.value)
verifyNoRepositoryDeletion()
verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -2696,7 +2685,7 @@ class NodeManagerImplTest {
assertNull(nodeManager.nodeDBbyNodeNum[num])
assertFalse(nodeManager.isNodeDbReady.value)
verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
- verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceNotifications.showNewNodeSeenNotification(any(), any()) }
}
@Test
@@ -2771,8 +2760,8 @@ class NodeManagerImplTest {
advanceUntilIdle()
// Assert
- verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num1) }
- verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num2) }
+ verify(VerifyMode.atLeast(1)) { serviceNotifications.cancelNewNodeNotification(num1) }
+ verify(VerifyMode.atLeast(1)) { serviceNotifications.cancelNewNodeNotification(num2) }
}
@Test
@@ -2782,7 +2771,7 @@ class NodeManagerImplTest {
nodeManager.handleReceivedUser(num, userWithKey(key, "Retiring", "RT"), manuallyVerified = false)
advanceUntilIdle()
var nodePresentAtCancellation: Boolean? = null
- every { notificationManager.cancel(num) } calls
+ every { serviceNotifications.cancelNewNodeNotification(num) } calls
{
nodePresentAtCancellation = num in nodeManager.nodeDBbyNodeNum
}
@@ -2791,7 +2780,7 @@ class NodeManagerImplTest {
assertEquals(false, nodePresentAtCancellation, "retirement must commit before cancellation side effects")
assertNull(nodeManager.nodeDBbyNodeNum[num])
- verify(VerifyMode.exactly(1)) { notificationManager.cancel(num) }
+ verify(VerifyMode.exactly(1)) { serviceNotifications.cancelNewNodeNotification(num) }
}
@Test
@@ -2801,8 +2790,8 @@ class NodeManagerImplTest {
val key = ByteArray(32) { 0x03 }.toByteString()
val titleStarted = CompletableDeferred()
val releaseTitle = CompletableDeferred()
- val dispatched = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ val dispatched = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
nodeManager.notificationTitleFormatter = { shortName ->
titleStarted.complete(Unit)
releaseTitle.await()
@@ -2815,7 +2804,7 @@ class NodeManagerImplTest {
releaseTitle.complete(Unit)
advanceUntilIdle()
- assertTrue(dispatched.none { it.id == num && it.message == "NewNode" })
+ assertTrue(dispatched.none { it.num == num && it.user.long_name == "NewNode" })
}
@Test
@@ -2825,8 +2814,8 @@ class NodeManagerImplTest {
val key = ByteArray(32) { 0x04 }.toByteString()
val titleStarted = CompletableDeferred()
val releaseTitle = CompletableDeferred()
- val dispatched = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ val dispatched = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
nodeManager.notificationTitleFormatter = { shortName ->
titleStarted.complete(Unit)
releaseTitle.await()
@@ -2839,7 +2828,7 @@ class NodeManagerImplTest {
releaseTitle.complete(Unit)
advanceUntilIdle()
- assertTrue(dispatched.none { it.id == num && it.message == "NewNode" })
+ assertTrue(dispatched.none { it.num == num && it.user.long_name == "NewNode" })
}
@Test
@@ -2850,8 +2839,8 @@ class NodeManagerImplTest {
val key2 = ByteArray(32) { 0x06 }.toByteString()
val titleStarted = CompletableDeferred()
val releaseTitle = CompletableDeferred()
- val dispatched = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ val dispatched = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
nodeManager.notificationTitleFormatter = { shortName ->
titleStarted.complete(Unit)
releaseTitle.await()
@@ -2866,7 +2855,7 @@ class NodeManagerImplTest {
val node = nodeManager.nodeDBbyNodeNum[num]
assertEquals("Other", node?.user?.long_name)
- assertTrue(dispatched.none { it.message == "First" }, "notification for 'First' must be suppressed")
+ assertTrue(dispatched.none { it.user.long_name == "First" }, "notification for 'First' must be suppressed")
}
@Test
@@ -2878,14 +2867,15 @@ class NodeManagerImplTest {
advanceUntilIdle()
nodeManager.applyTrustedIdentityMigrations(listOf(oldNum))
advanceUntilIdle()
- val replayDispatches = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(replayDispatches)) } returns true
+ val replayDispatches = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(replayDispatches), any()) } returns
+ Unit
// Now replay: the canonical number (crc32(key)) has NOT appeared yet
nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false)
advanceUntilIdle()
// The replayed node should NOT appear in nodeDBbyNodeNum at oldNum
assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
- assertTrue(replayDispatches.none { it.id == oldNum })
+ assertTrue(replayDispatches.none { it.num == oldNum })
}
@Test
@@ -2902,7 +2892,9 @@ class NodeManagerImplTest {
advanceUntilIdle()
assertNull(nodeManager.nodeDBbyNodeNum[num])
- verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatch(any()) }
+ verifySuspend(mode = VerifyMode.exactly(0)) {
+ serviceNotifications.showNewNodeSeenNotification(any(), any())
+ }
}
@Test
@@ -2917,8 +2909,8 @@ class NodeManagerImplTest {
advanceUntilIdle()
// Capture dispatches to verify the reuse notification specifically,
// not the initial sighting from the setup above.
- val dispatched = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ val dispatched = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
nodeManager.setNodeDbReady(true)
// Replay with different key — should be allowed as legitimate reuse
nodeManager.handleReceivedUser(num, userWithKey(newKey, "New", "NW"), manuallyVerified = false)
@@ -2928,7 +2920,7 @@ class NodeManagerImplTest {
assertEquals("New", newNode.user.long_name)
assertEquals(
1,
- dispatched.count { it.id == num && it.message == "New" },
+ dispatched.count { it.num == num && it.user.long_name == "New" },
"exactly one notification for the reuse at $num",
)
}
@@ -2978,15 +2970,16 @@ class NodeManagerImplTest {
nodeManager.applyTrustedIdentityMigrations(listOf(oldNum))
advanceUntilIdle()
// Early replay of old number User packet — should be suppressed
- val dispatchedBefore = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(dispatchedBefore)) } returns true
+ val dispatchedBefore = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatchedBefore), any()) } returns
+ Unit
nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false)
advanceUntilIdle()
// The old number should NOT be in nodeDB
assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
// No notification should have been dispatched for the old number replay
// (the notification dispatch was captured; check none is for oldNum)
- val oldNumNotifications = dispatchedBefore.filter { it.id == oldNum }
+ val oldNumNotifications = dispatchedBefore.filter { it.num == oldNum }
assertTrue(oldNumNotifications.isEmpty(), "No notification should be dispatched for retired oldNum")
}
@@ -2996,11 +2989,11 @@ class NodeManagerImplTest {
val num = 9000000000.toInt()
val key = ByteArray(32) { 0x0b }.toByteString()
nodeManager.setMyNodeNum(1230588578)
- val dispatched = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ val dispatched = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
nodeManager.handleReceivedUser(num, userWithKey(key, "Genuine Remote", "GR"), manuallyVerified = false)
advanceUntilIdle()
- val numNotifications = dispatched.filter { it.id == num }
+ val numNotifications = dispatched.filter { it.num == num }
assertEquals(1, numNotifications.size, "Exactly one notification for genuine new node")
}
@@ -3129,13 +3122,13 @@ class NodeManagerImplTest {
)
// Stale same-key replay still suppressed, no notification dispatched for the retired old number.
- val dispatched = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ val dispatched = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(dispatched), any()) } returns Unit
nodeManager.handleReceivedUser(num, userWithKey(validPk, "Replay", "RP"), manuallyVerified = false)
advanceUntilIdle()
assertNull(nodeManager.nodeDBbyNodeNum[num])
- assertTrue(dispatched.none { it.id == num }, "no notification for retired-number replay")
+ assertTrue(dispatched.none { it.num == num }, "no notification for retired-number replay")
}
@Test
@@ -3250,20 +3243,23 @@ class NodeManagerImplTest {
advanceUntilIdle()
// Same-key replay still suppressed after the repeated migration.
- val suppressedDispatches = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(suppressedDispatches)) } returns true
+ val suppressedDispatches = mutableListOf()
+ everySuspend {
+ serviceNotifications.showNewNodeSeenNotification(capture(suppressedDispatches), any())
+ } returns Unit
nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Replay", "RP"), manuallyVerified = false)
advanceUntilIdle()
assertNull(
nodeManager.nodeDBbyNodeNum[num],
"same-key replay after repeated migration must stay suppressed",
)
- assertTrue(suppressedDispatches.none { it.id == num })
+ assertTrue(suppressedDispatches.none { it.num == num })
// Distinct valid unrepresented key is still accepted as a legitimate reuse, clearing retirement + hint and
// emitting exactly one replacement notification.
- val reuseDispatches = mutableListOf()
- everySuspend { notificationManager.dispatch(capture(reuseDispatches)) } returns true
+ val reuseDispatches = mutableListOf()
+ everySuspend { serviceNotifications.showNewNodeSeenNotification(capture(reuseDispatches), any()) } returns
+ Unit
nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Replacement", "NP"), manuallyVerified = false)
advanceUntilIdle()
@@ -3271,6 +3267,6 @@ class NodeManagerImplTest {
assertNotNull(reused)
assertEquals("Replacement", reused.user.long_name)
assertEquals(reuseKey, reused.publicKey)
- assertEquals(1, reuseDispatches.count { it.id == num && it.message == "Replacement" })
+ assertEquals(1, reuseDispatches.count { it.num == num && it.user.long_name == "Replacement" })
}
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
index 9199358a3d..33610bd1b9 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
@@ -24,6 +24,8 @@ import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
+import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@@ -35,8 +37,8 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.RadioSessionLease
@@ -58,7 +60,7 @@ class TelemetryPacketHandlerImplTest {
private val nodeManager = mock(MockMode.autofill)
private val connectionManager = mock(MockMode.autofill)
- private val notificationManager = mock(MockMode.autofill)
+ private val serviceNotifications = mock(MockMode.autofill)
private val radioInterfaceService = mock(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
@@ -91,7 +93,7 @@ class TelemetryPacketHandlerImplTest {
TelemetryPacketHandlerImpl(
nodeManager = nodeManager,
connectionManager = lazy { connectionManager },
- notificationManager = notificationManager,
+ serviceNotifications = serviceNotifications,
radioInterfaceService = radioInterfaceService,
scope = testScope.asServiceScope(),
)
@@ -337,6 +339,88 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession)
advanceUntilIdle()
- // No dispatch call — battery is healthy
+ verifySuspend(VerifyMode.not) { serviceNotifications.showLowBatteryNotification(any(), any()) }
+ verify { serviceNotifications.cancelLowBatteryNotification(Node(num = myNodeNum)) }
}
+
+ @Test
+ fun `low local battery posts on the local low-battery path`() = testScope.runTest {
+ handler.handleTelemetry(
+ makeTelemetryPacket(myNodeNum, lowBatteryTelemetry()),
+ makeDataPacket(myNodeNum),
+ myNodeNum,
+ radioSession,
+ )
+ advanceUntilIdle()
+
+ verifySuspend { serviceNotifications.showLowBatteryNotification(Node(num = myNodeNum), isRemote = false) }
+ }
+
+ @Test
+ fun `later low readings refresh the warning instead of alerting again`() = testScope.runTest {
+ repeat(2) {
+ handler.handleTelemetry(
+ makeTelemetryPacket(myNodeNum, lowBatteryTelemetry()),
+ makeDataPacket(myNodeNum),
+ myNodeNum,
+ radioSession,
+ )
+ advanceUntilIdle()
+ }
+
+ verifySuspend(VerifyMode.exactly(1)) { serviceNotifications.showLowBatteryNotification(any(), any()) }
+ verifySuspend(VerifyMode.exactly(1)) { serviceNotifications.updateLowBatteryNotification(any(), any()) }
+ }
+
+ @Test
+ fun `a reading with voltage but no battery level neither warns nor clears`() = testScope.runTest {
+ val noLevel =
+ Telemetry.Builder()
+ .also { wb ->
+ wb.time = 1700000000
+ wb.device_metrics = DeviceMetrics.Builder().also { wb -> wb.voltage = 3.4f }.build()
+ }
+ .build()
+
+ handler.handleTelemetry(
+ makeTelemetryPacket(myNodeNum, noLevel),
+ makeDataPacket(myNodeNum),
+ myNodeNum,
+ radioSession,
+ )
+ advanceUntilIdle()
+
+ verifySuspend(VerifyMode.not) { serviceNotifications.showLowBatteryNotification(any(), any()) }
+ verify(VerifyMode.not) { serviceNotifications.cancelLowBatteryNotification(any()) }
+ }
+
+ @Test
+ fun `low battery on a remote favorite posts on the remote low-battery path`() = testScope.runTest {
+ val favorite = Node(num = remoteNodeNum, isFavorite = true)
+ every { nodeManager.nodeDBbyNodeNum } returns
+ mapOf(myNodeNum to Node(num = myNodeNum), remoteNodeNum to favorite)
+
+ handler.handleTelemetry(
+ makeTelemetryPacket(remoteNodeNum, lowBatteryTelemetry()),
+ makeDataPacket(remoteNodeNum),
+ myNodeNum,
+ radioSession,
+ )
+ advanceUntilIdle()
+
+ verifySuspend { serviceNotifications.showLowBatteryNotification(favorite, isRemote = true) }
+ }
+
+ private fun lowBatteryTelemetry() = Telemetry.Builder()
+ .also { wb ->
+ wb.time = 1700000000
+ wb.device_metrics =
+ DeviceMetrics.Builder()
+ .also { wb ->
+ wb.battery_level = 10
+ wb.voltage = 3.4f
+ }
+ .build()
+ }
+ .build()
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt
index 16b3d5c849..ae32cb7144 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt
@@ -20,13 +20,28 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
+import org.meshtastic.core.resources.UiText
data class FirmwareUpdateStatus(val isOtaUpdateActive: Boolean = false, val isAwaitingOtaStatus: Boolean = false)
+/**
+ * What a running firmware update is doing, for surfaces outside the firmware screen. [percent] is null when unknown.
+ */
+data class FirmwareUpdateProgress(val message: UiText, val percent: Int?)
+
class FirmwareUpdateStatusRepository {
private val _status = MutableStateFlow(FirmwareUpdateStatus())
val status: StateFlow = _status.asStateFlow()
+ private val _progress = MutableStateFlow(null)
+
+ /** Null whenever no update is transferring, including while the flow waits on the user. */
+ val progress: StateFlow = _progress.asStateFlow()
+
+ fun publishProgress(progress: FirmwareUpdateProgress?) {
+ _progress.value = progress
+ }
+
fun beginOtaUpdate() {
_status.value = FirmwareUpdateStatus(isOtaUpdateActive = true)
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
index 4d72e52ba4..58b30764ce 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
@@ -17,6 +17,8 @@
package org.meshtastic.core.repository
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.FirmwareUpdateNotice
+import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.model.Node
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
@@ -24,10 +26,9 @@ import org.meshtastic.proto.Telemetry
const val SERVICE_NOTIFY_ID = 101
/**
- * Mesh-domain notification builder. Provides high-level operations for the message arrival, waypoint, reaction, new
- * node, low-battery, and client notification flows specific to this app. Implementations are expected to render the
- * platform notification themselves; the generic dispatch primitive is [NotificationManager] (which posts/cancels opaque
- * [Notification] records and is *not* domain-aware).
+ * The one notification API for shared code: every notification the app posts or cancels goes through here, and each
+ * platform renders it with its own channels, styles and tap targets. [NotificationManager] is the desktop's dispatch
+ * primitive underneath its implementation, not a second way in.
*/
@Suppress("TooManyFunctions")
interface MeshNotificationManager {
@@ -63,13 +64,42 @@ interface MeshNotificationManager {
isSilent: Boolean = false,
)
- fun showAlertNotification(contactKey: String, name: String, alert: String)
+ suspend fun showAlertNotification(contactKey: String, name: String, alert: String)
- fun showNewNodeSeenNotification(node: Node)
+ suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer)
- fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean)
+ /** [title] arrives resolved: the caller revalidates [node]'s identity right before posting, with no suspension. */
+ suspend fun showNewNodeSeenNotification(node: Node, title: String)
- fun showClientNotification(clientNotification: ClientNotification)
+ fun cancelNewNodeNotification(nodeNum: Int)
+
+ /** Posts the low-battery warning for [node], alerting once. */
+ suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean)
+
+ /** Refreshes a still-showing low-battery warning with [node]'s current level; never re-posts a dismissed one. */
+ suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean)
+
+ fun cancelLowBatteryNotification(node: Node)
+
+ /**
+ * [title] and [severity] come from the notification's kind, which shared code classifies once for every platform.
+ */
+ suspend fun showClientNotification(
+ clientNotification: ClientNotification,
+ title: String,
+ severity: Notification.Type,
+ )
+
+ fun clearClientNotification(clientNotification: ClientNotification)
+
+ /** True when the platform presents [clientNotification] natively, so the in-app modal must not show it too. */
+ fun suppressClientNotificationModal(clientNotification: ClientNotification): Boolean = false
+
+ /** Returns true only when the platform accepted the notification, so the caller can record it as shown. */
+ suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean
+
+ /** Returns true only when the platform accepted the notification. [title] and [message] arrive resolved. */
+ suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean
/**
* Suspending because Android rebuilds the group summary here, and the summary's labels come from string resources —
@@ -84,8 +114,4 @@ interface MeshNotificationManager {
* to dismissing the conversation, which also resolves the spinner.
*/
suspend fun refreshConversationAfterReply(contactKey: String) = cancelMessageNotification(contactKey)
-
- fun cancelLowBatteryNotification(node: Node)
-
- fun clearClientNotification(notification: ClientNotification)
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
index 678a78f2a1..6365770057 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
@@ -16,21 +16,14 @@
*/
package org.meshtastic.core.repository
+/** A notification as the desktop dispatch primitive ([NotificationManager]) sees it. */
data class Notification(
val title: String,
val message: String,
val type: Type = Type.Info,
val category: Category = Category.Message,
- val contactKey: String? = null,
val isSilent: Boolean = false,
- val group: String? = null,
val id: Int? = null,
- /**
- * Optional deep-link URI invoked when the user taps the notification. Platform implementations are responsible for
- * converting this into the appropriate intent / activation action. When null, tapping the notification has no
- * effect.
- */
- val deepLinkUri: String? = null,
) {
enum class Type {
None,
@@ -46,7 +39,10 @@ data class Notification(
Alert,
Service,
- /** Advisory Mesh Beacon invitations from other meshes — low-importance, its own channel. */
+ /** Advisory Mesh Beacon invitations from other meshes. */
MeshBeacon,
+
+ /** Notices from the radio's firmware (ClientNotification). */
+ Client,
}
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt
index cce1c004c0..f163e24a4e 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt
@@ -16,14 +16,10 @@
*/
package org.meshtastic.core.repository
-import org.meshtastic.proto.ClientNotification
-
/**
- * Platform-agnostic notification dispatch primitive. Posts opaque [Notification] records, cancels by id, or wipes all
- * active notifications. Intended as the lowest layer of the notification stack.
- *
- * Domain-specific notification builders (mesh message arrivals, low-battery alerts, etc.) live in
- * [MeshNotificationManager], which composes over this dispatcher.
+ * Dispatch primitive underneath a platform's [MeshNotificationManager]: posts opaque [Notification] records, cancels by
+ * id, or wipes all active notifications. Shared code never calls it directly; the desktop implementation composes over
+ * it, while Android renders its notifications natively.
*/
interface NotificationManager {
/**
@@ -33,15 +29,6 @@ interface NotificationManager {
*/
suspend fun dispatch(notification: Notification): Boolean
- /** Platform hook for ClientNotifications that should use a native presentation instead of the global modal. */
- fun suppressClientNotificationModal(notification: ClientNotification): Boolean = false
-
- /** Platform-specific ClientNotification delivery; defaults to the ordinary notification path. */
- suspend fun dispatchClientNotification(
- notification: Notification,
- clientNotification: ClientNotification,
- ): Boolean = dispatch(notification)
-
fun cancel(id: Int)
fun cancelAll()
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 768e5585fa..706b987f5e 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1076,15 +1076,28 @@
Meshtastic
Alert notifications
+ Critical alerts sent by nodes on the mesh.
Meshtastic
Broadcast message notifications
+ Messages sent to your channels.
+ Radio notifications
+ Notices from your radio, such as key verification requests and security warnings.
+ Update and connection notifications
+ Firmware updates for your radio, and problems reconnecting to it.
Low battery notifications
+ Your radio's battery is running low.
Low battery notifications (favorite nodes)
+ A favorite node's battery is running low.
Mesh invitation notifications
+ Invitations to join a nearby mesh.
Direct message notifications
+ Messages sent directly to you.
New node notifications
+ Nodes heard for the first time.
Service notifications
+ Shows the connection to your radio while Meshtastic runs in the background.
Waypoint notifications
+ Waypoints shared on the mesh, and geofence crossings.
Message
Message delivery status
@@ -1303,10 +1316,13 @@
Not now
Note
Notes
+
+ Mesh
Notifications are turned off and Android will not ask again. Turn them on in app settings to hear about new messages and alerts.
Without notifications, Meshtastic cannot alert you to new messages, new nodes, or a low battery while the app is in the background.
Notifications are how Meshtastic reaches you when the app is not open: new messages, newly discovered nodes, and a radio running low on battery. Nothing else changes if you decline.
Meshtastic uses notifications to keep you updated on new messages and other important events. You can update your notification permissions at any time from settings.
+ %1$s to “%2$s”
Notifications for channel and direct messages.
Notifications for low battery alerts for the connected device.
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/app/MainActivity.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/app/MainActivity.kt
index f7e5e38ce7..a5bc0e5126 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/app/MainActivity.kt
@@ -19,7 +19,7 @@ package org.meshtastic.app
import android.app.Activity
/**
- * Test-only stub for the real `MainActivity` in the `:androidApp` module. `AndroidNotificationManager` resolves the
+ * Test-only stub for the real `MainActivity` in the `:androidApp` module. `MeshNotificationManagerImpl` resolves the
* activity by FQN via `Class.forName(...)` to avoid pulling `:androidApp` into `:core:service` as a Gradle dependency.
* This stub lets unit tests exercise the deep-link `PendingIntent` construction path without that dependency.
*/
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
deleted file mode 100644
index e221b90897..0000000000
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
+++ /dev/null
@@ -1,414 +0,0 @@
-/*
- * 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 android.app.NotificationChannel
-import android.app.NotificationManager
-import android.content.Context
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.notificationId
-import org.meshtastic.proto.ClientNotification
-import org.meshtastic.proto.DuplicatedPublicKey
-import org.meshtastic.proto.KeyVerificationFinal
-import org.meshtastic.proto.KeyVerificationNumberInform
-import org.meshtastic.proto.KeyVerificationNumberRequest
-import org.meshtastic.proto.LogRecord
-import org.meshtastic.proto.LowEntropyKey
-import org.robolectric.Shadows.shadowOf
-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
-
-@RunWith(AndroidJUnit4::class)
-@Config(sdk = [34])
-class AndroidNotificationManagerTest {
-
- private lateinit var context: Context
- private lateinit var systemNotificationManager: NotificationManager
-
- @Before
- fun setUp() {
- context = ApplicationProvider.getApplicationContext()
- systemNotificationManager = context.getSystemService(NotificationManager::class.java)!!
- clearManagedChannels()
- systemNotificationManager.cancelAll()
- }
-
- @After
- fun tearDown() {
- clearManagedChannels()
- systemNotificationManager.cancelAll()
- }
-
- @Test
- fun `removeLegacyCategoryChannels deletes legacy channels and keeps canonical channels`() {
- createChannel("NodeEvent")
- createChannel(NotificationChannels.NEW_NODES)
-
- systemNotificationManager.removeLegacyCategoryChannels()
-
- assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
- assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.NEW_NODES))
- }
-
- @Test
- fun `dispatch removes legacy node channel and creates canonical node channel`() = runTest {
- createChannel("NodeEvent")
-
- val manager = AndroidNotificationManager(context)
- manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
-
- assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
- assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.NEW_NODES))
- }
-
- @Test
- fun `dispatch routes node event notifications to canonical new nodes channel`() = runTest {
- val manager = AndroidNotificationManager(context)
-
- manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
-
- val posted = shadowOf(systemNotificationManager).allNotifications.last()
- assertEquals(NotificationChannels.NEW_NODES, posted.channelId)
- }
-
- @Test
- fun `dispatch reports false when its notification channel is disabled`() = runTest {
- createChannel(NotificationChannels.NEW_NODES, NotificationManager.IMPORTANCE_NONE)
- val manager = AndroidNotificationManager(context)
-
- val dispatched =
- manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
-
- assertFalse(dispatched)
- assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
- }
-
- @Test
- fun `removeLegacyCategoryChannels removes all known legacy category channels`() {
- NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
-
- systemNotificationManager.removeLegacyCategoryChannels()
-
- NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
- assertNull(systemNotificationManager.getNotificationChannel(legacyId))
- }
- }
-
- @Test
- fun `removeLegacyCategoryChannels is idempotent`() {
- createChannel("NodeEvent")
-
- systemNotificationManager.removeLegacyCategoryChannels()
- systemNotificationManager.removeLegacyCategoryChannels()
-
- assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
- }
-
- @Test
- fun `dispatch routes all categories to canonical channels`() = runTest {
- val manager = AndroidNotificationManager(context)
-
- assertDispatchesToChannel(manager, Notification.Category.Message, NotificationChannels.MESSAGES)
- assertDispatchesToChannel(manager, Notification.Category.NodeEvent, NotificationChannels.NEW_NODES)
- assertDispatchesToChannel(manager, Notification.Category.Battery, NotificationChannels.LOW_BATTERY)
- assertDispatchesToChannel(manager, Notification.Category.Alert, NotificationChannels.ALERTS)
- assertDispatchesToChannel(manager, Notification.Category.Service, NotificationChannels.SERVICE)
- }
-
- @Test
- fun `dispatch attaches deep-link PendingIntent when deepLinkUri is set`() = runTest {
- registerStubMainActivity()
- val manager = AndroidNotificationManager(context)
- val deepLink = "meshtastic://meshtastic/nodes/1234"
-
- manager.dispatch(
- Notification(
- title = "New node",
- message = "Long Name",
- category = Notification.Category.NodeEvent,
- id = 1234,
- deepLinkUri = deepLink,
- ),
- )
-
- val posted = shadowOf(systemNotificationManager).allNotifications.last()
- val pendingIntent =
- requireNotNull(posted.contentIntent) { "Expected contentIntent to be set when deepLinkUri is provided" }
- val shadowPendingIntent = shadowOf(pendingIntent)
- val savedIntent = shadowPendingIntent.savedIntent
- assertEquals(android.content.Intent.ACTION_VIEW, savedIntent.action)
- assertEquals(deepLink, savedIntent.data?.toString())
- assertEquals("org.meshtastic.app.MainActivity", savedIntent.component?.className)
- }
-
- @Test
- fun `dispatch leaves contentIntent unset when deepLinkUri is null`() = runTest {
- val manager = AndroidNotificationManager(context)
-
- manager.dispatch(Notification(title = "Plain", message = "No tap", category = Notification.Category.NodeEvent))
-
- val posted = shadowOf(systemNotificationManager).allNotifications.last()
- assertNull(posted.contentIntent)
- }
-
- @Test
- fun `dispatch uses provided notification id as system id`() = runTest {
- val manager = AndroidNotificationManager(context)
- val explicitId = 4242
-
- manager.dispatch(
- Notification(
- title = "With id",
- message = "explicit",
- category = Notification.Category.NodeEvent,
- id = explicitId,
- ),
- )
-
- // Cancellation by the same id should remove the posted notification.
- assertEquals(1, shadowOf(systemNotificationManager).allNotifications.size)
- manager.cancel(explicitId)
- assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
- }
-
- @Test
- fun `client notification identity cancels the notification posted with that identity`() = runTest {
- val manager = AndroidNotificationManager(context)
- val clientNotification =
- ClientNotification.Builder()
- .also { wb ->
- wb.message = "Protected position advisory"
- wb.reply_id = 123
- }
- .build()
- val id = clientNotification.notificationId()
-
- manager.dispatch(
- Notification(
- title = "Client notification",
- message = clientNotification.message,
- category = Notification.Category.Alert,
- id = id,
- ),
- )
-
- assertEquals(1, shadowOf(systemNotificationManager).allNotifications.size)
- manager.cancel(clientNotification.notificationId())
- assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
- }
-
- @Test
- fun `exact protected position advisory is recognized`() {
- val manager = AndroidNotificationManager(context)
-
- assertTrue(manager.suppressClientNotificationModal(protectedPositionAdvisory(replyId = 123, time = 1_000)))
- }
-
- @Test
- fun `protected position advisory predicate rejects every near miss`() {
- val manager = AndroidNotificationManager(context)
- val advisory = protectedPositionAdvisory(replyId = 123, time = 1_000)
- val nearMisses =
- listOf(
- advisory.newBuilder().also { wb -> wb.message = "Location sharing is disabled" }.build(),
- advisory.newBuilder().also { wb -> wb.level = LogRecord.Level.INFO }.build(),
- advisory.newBuilder().also { wb -> wb.reply_id = 0 }.build(),
- advisory.newBuilder().also { wb -> wb.reply_id = null }.build(),
- advisory
- .newBuilder()
- .also { wb -> wb.key_verification_number_inform = KeyVerificationNumberInform.Builder().build() }
- .build(),
- advisory
- .newBuilder()
- .also { wb -> wb.key_verification_number_request = KeyVerificationNumberRequest.Builder().build() }
- .build(),
- advisory
- .newBuilder()
- .also { wb -> wb.key_verification_final = KeyVerificationFinal.Builder().build() }
- .build(),
- advisory
- .newBuilder()
- .also { wb -> wb.duplicated_public_key = DuplicatedPublicKey.Builder().build() }
- .build(),
- advisory.newBuilder().also { wb -> wb.low_entropy_key = LowEntropyKey.Builder().build() }.build(),
- advisory.newBuilder().also { wb -> wb.message = "Rebooting to WiFi OTA" }.build(),
- )
-
- nearMisses.forEach { notification ->
- assertFalse(manager.suppressClientNotificationModal(notification), notification.toString())
- }
- }
-
- @Test
- fun `protected position advisories replace one stable only-alert-once system notification`() = runTest {
- val manager = AndroidNotificationManager(context)
- val first = protectedPositionAdvisory(replyId = 123, time = 1_000)
- val second = protectedPositionAdvisory(replyId = 456, time = 2_000)
-
- listOf(first, second).forEach { advisory ->
- manager.dispatchClientNotification(
- Notification(
- title = "Client notification",
- message = advisory.message,
- category = Notification.Category.Alert,
- id = advisory.notificationId(),
- ),
- advisory,
- )
- }
-
- val posted = shadowOf(systemNotificationManager).allNotifications.single()
- assertTrue(posted.flags and android.app.Notification.FLAG_ONLY_ALERT_ONCE != 0)
- val active = systemNotificationManager.activeNotifications.single()
- assertEquals(first.notificationId(), active.id)
- }
-
- @Test
- fun `repeated generic client notifications with the same message coalesce into one tray entry`() = runTest {
- val manager = AndroidNotificationManager(context)
- // A near-miss of the protected-position predicate (message differs slightly), so it takes the plain
- // dispatch path — but reply_id/time still change on every firmware reply, exactly like the real advisory.
- val first =
- ClientNotification.Builder()
- .also { wb ->
- wb.message = "Location sharing is disabled"
- wb.reply_id = 100
- wb.time = 1_000
- }
- .build()
- val second =
- ClientNotification.Builder()
- .also { wb ->
- wb.message = "Location sharing is disabled"
- wb.reply_id = 200
- wb.time = 2_000
- }
- .build()
-
- listOf(first, second).forEach { cn ->
- manager.dispatchClientNotification(
- Notification(
- title = "Client notification",
- message = cn.message,
- category = Notification.Category.Alert,
- id = cn.notificationId(),
- ),
- cn,
- )
- }
-
- assertEquals(1, shadowOf(systemNotificationManager).allNotifications.size)
- }
-
- @Test
- fun `generic client notification does not enable only-alert-once`() = runTest {
- val manager = AndroidNotificationManager(context)
- val clientNotification =
- ClientNotification.Builder()
- .also { wb ->
- wb.message = "Generic warning"
- wb.reply_id = 123
- }
- .build()
-
- manager.dispatchClientNotification(
- Notification(
- title = "Client notification",
- message = clientNotification.message,
- category = Notification.Category.Alert,
- id = clientNotification.notificationId(),
- ),
- clientNotification,
- )
-
- val posted = shadowOf(systemNotificationManager).allNotifications.single()
- assertEquals(0, posted.flags and android.app.Notification.FLAG_ONLY_ALERT_ONCE)
- }
-
- private suspend fun assertDispatchesToChannel(
- manager: AndroidNotificationManager,
- category: Notification.Category,
- expectedChannelId: String,
- ) {
- systemNotificationManager.cancelAll()
- manager.dispatch(
- Notification(title = "Title-${category.name}", message = "Message-${category.name}", category = category),
- )
-
- val posted = shadowOf(systemNotificationManager).allNotifications.last()
- assertEquals(expectedChannelId, posted.channelId)
- }
-
- private fun createChannel(id: String, importance: Int = NotificationManager.IMPORTANCE_DEFAULT) {
- systemNotificationManager.createNotificationChannel(NotificationChannel(id, id, importance))
- }
-
- /**
- * Registers a stub `org.meshtastic.app.MainActivity` with the Robolectric `PackageManager` so that
- * `TaskStackBuilder.addNextIntentWithParentStack` does not throw `NameNotFoundException` when resolving the
- * activity that hosts deep-link intents. The real activity lives in `:androidApp`, which is intentionally not on
- * `:core:service`'s test classpath.
- */
- private fun registerStubMainActivity() {
- val componentName = android.content.ComponentName(context, "org.meshtastic.app.MainActivity")
- val activityInfo =
- android.content.pm.ActivityInfo().apply {
- name = componentName.className
- packageName = componentName.packageName
- exported = true
- }
- shadowOf(context.packageManager).addOrUpdateActivity(activityInfo)
- }
-
- private fun clearManagedChannels() {
- val channelIds =
- NotificationChannels.LEGACY_CATEGORY_IDS +
- listOf(
- NotificationChannels.SERVICE,
- NotificationChannels.MESSAGES,
- NotificationChannels.BROADCASTS,
- NotificationChannels.WAYPOINTS,
- NotificationChannels.ALERTS,
- NotificationChannels.NEW_NODES,
- NotificationChannels.LOW_BATTERY,
- NotificationChannels.LOW_BATTERY_REMOTE,
- NotificationChannels.CLIENT,
- )
-
- channelIds.forEach { channelId -> systemNotificationManager.deleteNotificationChannel(channelId) }
- }
-
- private fun protectedPositionAdvisory(replyId: Int, time: Int) = ClientNotification.Builder()
- .also { wb ->
- wb.message = "Location sharing is disabled on this channel"
- wb.reply_id = replyId
- wb.time = time
- wb.level = LogRecord.Level.WARNING
- }
- .build()
-}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/BootCompleteReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/BootCompleteReceiverTest.kt
index 2490ef6ca7..a29fcbabdf 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/BootCompleteReceiverTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/BootCompleteReceiverTest.kt
@@ -39,9 +39,9 @@ import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshPrefs
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.testing.FakeMeshNotificationManager
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@@ -101,10 +101,8 @@ class BootCompleteReceiverTest {
advanceUntilIdle()
assertTrue(shadowOf(application).allStartedServices.isEmpty(), "must not start a service that cannot connect")
- assertEquals(1, recordingNotifications.dispatched.size)
- assertEquals(Notification.Type.Warning, recordingNotifications.dispatched.single().type)
- assertTrue(recordingNotifications.dispatched.single().deepLinkUri?.contains("connections") == true)
- assertTrue(recordingNotifications.dispatched.single().message.contains("Bluetooth"))
+ val (_, message) = recordingNotifications.reconnectBlocked.single()
+ assertTrue(message.contains("Bluetooth"))
}
@Test
@@ -120,7 +118,7 @@ class BootCompleteReceiverTest {
advanceUntilIdle()
assertEquals(1, shadowOf(application).allStartedServices.size)
- assertTrue(recordingNotifications.dispatched.isEmpty())
+ assertTrue(recordingNotifications.reconnectBlocked.isEmpty())
}
@Test
@@ -205,7 +203,7 @@ class BootCompleteReceiverTest {
modules(
module {
single { meshPrefs }
- single { recordingNotifications }
+ single { recordingNotifications }
single {
CoroutineDispatchers(io = ioDispatcher, main = defaultDispatcher, default = defaultDispatcher)
}
@@ -215,20 +213,7 @@ class BootCompleteReceiverTest {
}
/** Captures what the receiver tried to tell the user when it declined to start the service. */
- private val recordingNotifications = RecordingNotificationManager()
-
- private class RecordingNotificationManager : NotificationManager {
- val dispatched = mutableListOf()
-
- override suspend fun dispatch(notification: Notification): Boolean {
- dispatched += notification
- return true
- }
-
- override fun cancel(id: Int) = Unit
-
- override fun cancelAll() = Unit
- }
+ private val recordingNotifications = FakeMeshNotificationManager()
/**
* Grants BLUETOOTH_CONNECT unless told otherwise. Robolectric denies runtime permissions by default, and a boot
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 0268b13de2..7f9b15289b 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
@@ -42,6 +42,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -116,6 +117,7 @@ class MeshNotificationManagerImplConversationTest {
},
radioConfigRepository = lazy { radioConfigRepository },
radioOperationLock = RadioOperationLock(),
+ firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
scope = scope.asServiceScope(),
)
@@ -238,6 +240,22 @@ class MeshNotificationManagerImplConversationTest {
)
}
+ @Test
+ fun `conversation actions work from a watch without opening the phone`() = runWithRenderScope { scope ->
+ val manager = createManager(scope).also { it.initChannels() }
+ mockHistory(message("hello", read = false, receivedTime = 1_000))
+
+ manager.updateMessageNotification("0^all", "Hawk Ridge", "hello", isBroadcast = true, channelName = "LongFast")
+ advanceUntilIdle()
+
+ val actions = activeByTag("message").single().notification.actions.orEmpty()
+ val reply = actions.single { it.semanticAction == Notification.Action.SEMANTIC_ACTION_REPLY }
+ assertTrue(reply.allowGeneratedReplies, "Smart Reply suggestions on a watch need generated replies allowed")
+ val thumbsUp = actions.single { it.semanticAction == Notification.Action.SEMANTIC_ACTION_THUMBS_UP }
+ assertEquals(false, thumbsUp.extras.getBoolean("android.support.action.showsUserInterface", true))
+ assertTrue(actions.any { it.semanticAction == Notification.Action.SEMANTIC_ACTION_MARK_AS_READ })
+ }
+
@Test
@Config(sdk = [29])
fun `conversation notifications post on Android 10`() = runWithRenderScope { scope ->
@@ -313,7 +331,7 @@ class MeshNotificationManagerImplConversationTest {
val manager = createManager(scope).also { it.initChannels() }
// SERVICE_NOTIFY_ID is 101; a node whose num is also 101 used to overwrite the foreground notification.
manager.updateServiceStateNotification(ConnectionState.Connected, telemetry = null)
- manager.showOrUpdateLowBatteryNotification(Node(num = 101), isRemote = false)
+ manager.showLowBatteryNotification(Node(num = 101), isRemote = false)
runUntilSettled {
systemNotificationManager.activeNotifications.any { it.id == 101 && it.tag == null } &&
activeByTag("low_battery").any { it.id == 101 }
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
new file mode 100644
index 0000000000..077532ea6b
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplRoutingTest.kt
@@ -0,0 +1,303 @@
+/*
+ * 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 android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.ActivityInfo
+import android.service.notification.StatusBarNotification
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.CoroutineScope
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.common.di.asServiceScope
+import org.meshtastic.core.common.state.RadioOperationLock
+import org.meshtastic.core.model.FirmwareUpdateDestination
+import org.meshtastic.core.model.FirmwareUpdateNotice
+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.runWithRenderScope
+import org.meshtastic.proto.ClientNotification
+import org.meshtastic.proto.DuplicatedPublicKey
+import org.meshtastic.proto.KeyVerificationFinal
+import org.meshtastic.proto.KeyVerificationNumberInform
+import org.meshtastic.proto.KeyVerificationNumberRequest
+import org.meshtastic.proto.LogRecord
+import org.meshtastic.proto.LowEntropyKey
+import org.meshtastic.proto.MeshBeacon
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.meshtastic.core.repository.Notification as MeshNotification
+
+/** Every notification that is not a conversation: its channel, tray slot, tap target and alerting flags. */
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+class MeshNotificationManagerImplRoutingTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val systemNotificationManager = context.getSystemService(NotificationManager::class.java)!!
+
+ @Before
+ fun setUp() {
+ val main = ComponentName(context, "org.meshtastic.app.MainActivity")
+ val activityInfo =
+ ActivityInfo().apply {
+ name = main.className
+ packageName = main.packageName
+ exported = true
+ }
+ shadowOf(context.packageManager).addOrUpdateActivity(activityInfo)
+ systemNotificationManager.cancelAll()
+ NotificationChannelSpec.entries.forEach { systemNotificationManager.deleteNotificationChannel(it.id) }
+ }
+
+ @After
+ fun tearDown() {
+ systemNotificationManager.cancelAll()
+ }
+
+ @Test
+ fun `a received waypoint posts on the waypoint channel and opens the map at it`() = runWithRenderScope { scope ->
+ createManager(scope).updateWaypointNotification("0^all", "Hawk Ridge", "Camp", waypointId = 42)
+
+ val posted = activeByTag("waypoint").single().notification
+ assertEquals(NotificationChannels.WAYPOINTS, posted.channelId)
+ assertNull(posted.group, "a waypoint is not a conversation and must stay out of the messages summary")
+ assertEquals("meshtastic://meshtastic/map?waypointId=42", tapTarget(posted))
+ }
+
+ @Test
+ fun `a critical alert posts as an alarm that opens its conversation`() = runWithRenderScope { scope ->
+ createManager(scope).showAlertNotification("0!abcd1234", "Hawk Ridge", "Fire at camp")
+
+ val posted = activeByTag("alert").single().notification
+ assertEquals(NotificationChannels.ALERTS, posted.channelId)
+ assertEquals(Notification.CATEGORY_ALARM, posted.category)
+ assertEquals("meshtastic://meshtastic/messages/0!abcd1234", tapTarget(posted))
+ }
+
+ @Test
+ fun `a mesh invitation posts on its own channel and opens discovery`() = runWithRenderScope { scope ->
+ val offer =
+ MeshBeaconOffer(fromNodeNum = 7, beacon = MeshBeacon.Builder().also { wb -> wb.message = "Join" }.build())
+
+ createManager(scope).showMeshBeaconNotification(offer)
+
+ val posted = activeByTag("mesh_beacon").single()
+ assertEquals(7, posted.id)
+ assertEquals(NotificationChannels.MESH_BEACON, posted.notification.channelId)
+ assertEquals("meshtastic://meshtastic/discovery", tapTarget(posted.notification))
+ }
+
+ @Test
+ fun `a new node posts under its own tag and is cancelled by node number`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+
+ manager.showNewNodeSeenNotification(Node(num = 101), "New node seen: N101")
+
+ val posted = activeByTag("new_node").single()
+ assertEquals(101, posted.id)
+ assertEquals(NotificationChannels.NEW_NODES, posted.notification.channelId)
+ assertEquals("New node seen: N101", posted.notification.extras.getString(Notification.EXTRA_TITLE))
+ assertEquals("meshtastic://meshtastic/nodes/101", tapTarget(posted.notification))
+
+ manager.cancelNewNodeNotification(101)
+ assertTrue(activeByTag("new_node").isEmpty())
+ }
+
+ @Test
+ fun `low battery posts on the channel for whose battery it is`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+
+ manager.showLowBatteryNotification(Node(num = 1), isRemote = false)
+ manager.showLowBatteryNotification(Node(num = 2), isRemote = true)
+
+ val byNode = activeByTag("low_battery").associateBy { it.id }
+ assertEquals(NotificationChannels.LOW_BATTERY, byNode.getValue(1).notification.channelId)
+ assertEquals(NotificationChannels.LOW_BATTERY_REMOTE, byNode.getValue(2).notification.channelId)
+ assertEquals("meshtastic://meshtastic/nodes/2", tapTarget(byNode.getValue(2).notification))
+ // Ongoing notifications never bridge to a watch.
+ assertEquals(0, byNode.getValue(1).notification.flags and Notification.FLAG_ONGOING_EVENT)
+ }
+
+ @Test
+ fun `a low-battery refresh never brings back a dismissed warning`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+ val node = Node(num = 3)
+
+ manager.showLowBatteryNotification(node, isRemote = false)
+ manager.cancelLowBatteryNotification(node)
+ manager.updateLowBatteryNotification(node, isRemote = false)
+
+ assertTrue(activeByTag("low_battery").isEmpty())
+ }
+
+ @Test
+ fun `a warning still building when the battery recovers is never posted`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+ val node = Node(num = 4)
+ manager.beforeLowBatteryPost = { manager.cancelLowBatteryNotification(node) }
+
+ manager.showLowBatteryNotification(node, isRemote = false)
+
+ assertTrue(activeByTag("low_battery").isEmpty())
+ }
+
+ @Test
+ fun `radio notices post on their own channel and clear by identity`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+ val notice = ClientNotification.Builder().also { wb -> wb.message = "Generic warning" }.build()
+
+ manager.showClientNotification(notice, "Radio notice", MeshNotification.Type.Warning)
+
+ val posted = activeByTag("client").single()
+ assertEquals(notice.notificationId(), posted.id)
+ assertEquals(NotificationChannels.CLIENT, posted.notification.channelId)
+ assertEquals(0, posted.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE)
+
+ manager.clearClientNotification(notice)
+ assertTrue(activeByTag("client").isEmpty())
+ }
+
+ @Test
+ fun `repeated position advisories share one tray slot and alert once`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+ val first = protectedPositionAdvisory(replyId = 123, time = 1_000)
+ val second = protectedPositionAdvisory(replyId = 456, time = 2_000)
+
+ listOf(first, second).forEach { manager.showClientNotification(it, "Radio notice", MeshNotification.Type.Info) }
+
+ val posted = activeByTag("client").single()
+ assertTrue(posted.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0)
+ assertTrue(manager.suppressClientNotificationModal(first))
+ }
+
+ @Test
+ fun `only the exact position advisory skips the in-app modal`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+ val advisory = protectedPositionAdvisory(replyId = 123, time = 1_000)
+ val nearMisses =
+ listOf(
+ advisory.newBuilder().also { wb -> wb.message = "Location sharing is disabled" }.build(),
+ advisory.newBuilder().also { wb -> wb.level = LogRecord.Level.INFO }.build(),
+ advisory.newBuilder().also { wb -> wb.reply_id = 0 }.build(),
+ advisory.newBuilder().also { wb -> wb.reply_id = null }.build(),
+ advisory
+ .newBuilder()
+ .also { wb -> wb.key_verification_number_inform = KeyVerificationNumberInform.Builder().build() }
+ .build(),
+ advisory
+ .newBuilder()
+ .also { wb -> wb.key_verification_number_request = KeyVerificationNumberRequest.Builder().build() }
+ .build(),
+ advisory
+ .newBuilder()
+ .also { wb -> wb.key_verification_final = KeyVerificationFinal.Builder().build() }
+ .build(),
+ advisory
+ .newBuilder()
+ .also { wb -> wb.duplicated_public_key = DuplicatedPublicKey.Builder().build() }
+ .build(),
+ advisory.newBuilder().also { wb -> wb.low_entropy_key = LowEntropyKey.Builder().build() }.build(),
+ advisory.newBuilder().also { wb -> wb.message = "Rebooting to WiFi OTA" }.build(),
+ )
+
+ assertTrue(manager.suppressClientNotificationModal(advisory))
+ nearMisses.forEach { assertFalse(manager.suppressClientNotificationModal(it), it.toString()) }
+ }
+
+ @Test
+ fun `a firmware update opens in-app firmware updates whatever its destination`() = runWithRenderScope { scope ->
+ val accepted = createManager(scope).showFirmwareUpdateNotice(FirmwareUpdateDestination.MeshtasticFlasher)
+
+ assertTrue(accepted)
+ val posted = activeByTag("firmware_update").single().notification
+ assertEquals(NotificationChannels.DEVICE_STATUS, posted.channelId)
+ assertEquals("meshtastic://meshtastic/firmware/update", tapTarget(posted))
+ }
+
+ @Test
+ fun `a firmware update on a blocked channel reports that it was not shown`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+ manager.ensureChannels()
+ systemNotificationManager.createNotificationChannel(
+ NotificationChannel(NotificationChannels.DEVICE_STATUS, "blocked", NotificationManager.IMPORTANCE_NONE),
+ )
+
+ assertFalse(manager.showFirmwareUpdateNotice(FirmwareUpdateDestination.AndroidUpdate))
+ assertTrue(activeByTag("firmware_update").isEmpty())
+ }
+
+ @Test
+ fun `the reconnect-blocked warning replaces itself and opens connections`() = runWithRenderScope { scope ->
+ val manager = createManager(scope)
+
+ repeat(2) { manager.showReconnectBlockedNotification("Meshtastic can't reconnect", "Bluetooth is off") }
+
+ val posted = activeByTag("reconnect_blocked").single().notification
+ assertEquals(NotificationChannels.DEVICE_STATUS, posted.channelId)
+ assertEquals("meshtastic://meshtastic/connections", tapTarget(posted))
+ }
+
+ private suspend fun MeshNotificationManagerImpl.showFirmwareUpdateNotice(destination: FirmwareUpdateDestination) =
+ showFirmwareUpdateNotification(
+ FirmwareUpdateNotice(
+ notificationKey = "node|2.8.0",
+ currentVersion = "2.7.0",
+ stableVersion = "2.8.0",
+ destination = destination,
+ ),
+ )
+
+ private fun createManager(scope: CoroutineScope) = MeshNotificationManagerImpl(
+ context = context,
+ packetRepository = lazy { error("Not used in this test") },
+ nodeRepository = lazy { error("Not used in this test") },
+ conversationShortcutPublisher = lazy { error("Not used in this test") },
+ radioConfigRepository = lazy { error("Not used in this test") },
+ radioOperationLock = RadioOperationLock(),
+ firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
+ scope = scope.asServiceScope(),
+ )
+
+ private fun activeByTag(tag: String): List =
+ systemNotificationManager.activeNotifications.filter { it.tag == tag }
+
+ private fun tapTarget(notification: Notification): String? =
+ shadowOf(notification.contentIntent).savedIntent.data?.toString()
+
+ private fun protectedPositionAdvisory(replyId: Int, time: Int) = ClientNotification.Builder()
+ .also { wb ->
+ wb.message = PROTECTED_POSITION_ADVISORY_MESSAGE
+ wb.reply_id = replyId
+ wb.time = time
+ wb.level = LogRecord.Level.WARNING
+ }
+ .build()
+}
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 102147b91f..27c0b40dbc 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 androidx.core.app.NotificationCompat
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import dev.mokkery.MockMode
@@ -27,8 +28,10 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Before
import org.junit.Test
@@ -37,11 +40,15 @@ 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.repository.FirmwareUpdateProgress
+import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.resources.Res
+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.local_stats_nodes
import org.meshtastic.core.testing.runUntilSettled
@@ -61,6 +68,7 @@ class MeshNotificationManagerImplTest {
private lateinit var context: Context
private lateinit var systemNotificationManager: NotificationManager
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
@Before
fun setUp() {
@@ -78,30 +86,76 @@ class MeshNotificationManagerImplTest {
}
@Test
- fun `initChannels removes legacy categories and creates canonical channels`() = runWithRenderScope { renderScope ->
- NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
- val notifications = createManager(renderScope)
- notifications.initChannels()
+ fun `initChannels removes legacy categories and has the service channel ready at once`() =
+ runWithRenderScope { renderScope ->
+ NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
+ val notifications = createManager(renderScope)
+ notifications.initChannels()
- NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
- assertNull(systemNotificationManager.getNotificationChannel(legacyId))
+ NotificationChannels.LEGACY_CATEGORY_IDS.forEach { legacyId ->
+ assertNull(systemNotificationManager.getNotificationChannel(legacyId))
+ }
+ // The foreground-service notification is posted right after initChannels returns, and the platform (not
+ // Robolectric) rejects a channel whose group does not exist yet.
+ val service = assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.SERVICE))
+ assertEquals(NotificationChannelGroupSpec.Device.id, service.group)
+ assertNotNull(systemNotificationManager.getNotificationChannelGroup(NotificationChannelGroupSpec.Device.id))
}
- val canonicalChannelIds =
- listOf(
- NotificationChannels.SERVICE,
- NotificationChannels.MESSAGES,
- NotificationChannels.BROADCASTS,
- NotificationChannels.WAYPOINTS,
- NotificationChannels.ALERTS,
- NotificationChannels.NEW_NODES,
- NotificationChannels.LOW_BATTERY,
- NotificationChannels.LOW_BATTERY_REMOTE,
- NotificationChannels.CLIENT,
- )
+ /**
+ * Pins every channel's importance and group. Importance is fixed when a channel is first created, so changing one
+ * here only reaches new installs, and lowering one below what shipped also lowers it on unmodified existing
+ * installs: an edit to this table is a product decision, not a refactor.
+ */
+ @Test
+ fun `every channel is created with its importance, group and description`() = runWithRenderScope { renderScope ->
+ // Labels load on real threads; under virtual time the label timeout would fire at once and fall back.
+ withContext(Dispatchers.Default) { createManager(renderScope).ensureChannels() }
- canonicalChannelIds.forEach { channelId ->
- assertNotNull(systemNotificationManager.getNotificationChannel(channelId))
+ NotificationChannelSpec.entries.forEach { spec ->
+ val (importance, group) =
+ when (spec) {
+ NotificationChannelSpec.Service ->
+ NotificationManager.IMPORTANCE_LOW to NotificationChannelGroupSpec.Device
+
+ NotificationChannelSpec.DirectMessages ->
+ NotificationManager.IMPORTANCE_HIGH to NotificationChannelGroupSpec.Messages
+
+ NotificationChannelSpec.Broadcasts ->
+ NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Messages
+
+ NotificationChannelSpec.Waypoints ->
+ NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Messages
+
+ NotificationChannelSpec.Alerts ->
+ NotificationManager.IMPORTANCE_HIGH to NotificationChannelGroupSpec.Messages
+
+ NotificationChannelSpec.NewNodes ->
+ NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Mesh
+
+ NotificationChannelSpec.MeshBeacon ->
+ NotificationManager.IMPORTANCE_LOW to NotificationChannelGroupSpec.Mesh
+
+ NotificationChannelSpec.LowBatteryRemote ->
+ NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Mesh
+
+ NotificationChannelSpec.LowBattery ->
+ NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Device
+
+ NotificationChannelSpec.Client ->
+ NotificationManager.IMPORTANCE_HIGH to NotificationChannelGroupSpec.Device
+
+ NotificationChannelSpec.DeviceStatus ->
+ NotificationManager.IMPORTANCE_DEFAULT to NotificationChannelGroupSpec.Device
+ }
+ val channel = assertNotNull(systemNotificationManager.getNotificationChannel(spec.id), spec.name)
+ assertEquals(importance, channel.importance, spec.name)
+ assertEquals(group.id, channel.group, spec.name)
+ assertEquals(getString(spec.nameRes), channel.name.toString(), spec.name)
+ assertEquals(getString(spec.descriptionRes), channel.description, spec.name)
+ }
+ NotificationChannelGroupSpec.entries.forEach { group ->
+ assertNotNull(systemNotificationManager.getNotificationChannelGroup(group.id), group.name)
}
}
@@ -161,6 +215,33 @@ class MeshNotificationManagerImplTest {
assertNotNull(activeServiceNotification())
}
+ @Test
+ fun `a running flash makes the service notification a promotable progress notification`() =
+ runWithRenderScope { renderScope ->
+ val notifications = createManager(renderScope)
+ notifications.initChannels()
+ notifications.updateServiceStateNotification(ConnectionState.Disconnected, populatedTelemetry())
+ runUntilSettled { activeServiceNotification() != null }
+
+ firmwareUpdateStatusRepository.publishProgress(
+ FirmwareUpdateProgress(UiText.DynamicString("Writing firmware"), percent = 42),
+ )
+ runUntilSettled { serviceExtras()?.getInt(Notification.EXTRA_PROGRESS) == 42 }
+
+ val posted = assertNotNull(activeServiceNotification()).notification
+ assertEquals(getString(Res.string.firmware_update_in_progress), serviceTitle())
+ assertEquals("Writing firmware", serviceExtras()?.getCharSequence(Notification.EXTRA_TEXT)?.toString())
+ assertTrue(NotificationCompat.isRequestPromotedOngoing(posted))
+ assertEquals("42%", posted.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
+
+ firmwareUpdateStatusRepository.publishProgress(null)
+ runUntilSettled {
+ activeServiceNotification()?.notification?.let { !NotificationCompat.isRequestPromotedOngoing(it) } ==
+ true
+ }
+ assertEquals(getString(Res.string.disconnected), serviceTitle())
+ }
+
@Test
fun `service state seeds local stats before the local node row is available`() = runWithRenderScope { renderScope ->
val stats =
@@ -190,6 +271,7 @@ class MeshNotificationManagerImplTest {
conversationShortcutPublisher = lazy { error("Not used in this test") },
radioConfigRepository = lazy { error("Not used in this test") },
radioOperationLock = RadioOperationLock(),
+ firmwareUpdateStatusRepository = firmwareUpdateStatusRepository,
scope = scope.asServiceScope(),
)
@@ -209,6 +291,10 @@ class MeshNotificationManagerImplTest {
private fun activeServiceNotification() =
systemNotificationManager.activeNotifications.singleOrNull { it.id == SERVICE_NOTIFY_ID }
+ private fun serviceExtras() = activeServiceNotification()?.notification?.extras
+
+ private fun serviceTitle() = serviceExtras()?.getCharSequence(Notification.EXTRA_TITLE)?.toString()
+
private fun createChannel(id: String) {
systemNotificationManager.createNotificationChannel(
NotificationChannel(id, id, NotificationManager.IMPORTANCE_DEFAULT),
@@ -216,20 +302,7 @@ class MeshNotificationManagerImplTest {
}
private fun clearManagedChannels() {
- val channelIds =
- NotificationChannels.LEGACY_CATEGORY_IDS +
- listOf(
- NotificationChannels.SERVICE,
- NotificationChannels.MESSAGES,
- NotificationChannels.BROADCASTS,
- NotificationChannels.WAYPOINTS,
- NotificationChannels.ALERTS,
- NotificationChannels.NEW_NODES,
- NotificationChannels.LOW_BATTERY,
- NotificationChannels.LOW_BATTERY_REMOTE,
- NotificationChannels.CLIENT,
- )
-
+ val channelIds = NotificationChannels.LEGACY_CATEGORY_IDS + NotificationChannelSpec.entries.map { it.id }
channelIds.forEach { channelId -> systemNotificationManager.deleteNotificationChannel(channelId) }
}
}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt
new file mode 100644
index 0000000000..30d981b717
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.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.service
+
+import android.content.Intent
+import androidx.test.core.app.ApplicationProvider
+import dev.mokkery.MockMode
+import dev.mokkery.mock
+import dev.mokkery.verifySuspend
+import kotlinx.coroutines.Dispatchers
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshNotificationManager
+import org.meshtastic.core.repository.RadioController
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class ReactionReceiverTest {
+
+ private val radioController: RadioController = mock(MockMode.autofill)
+ private val notificationManager: MeshNotificationManager = mock(MockMode.autofill)
+
+ @Before
+ fun setUp() {
+ startKoin {
+ modules(
+ module {
+ single { radioController }
+ single { notificationManager }
+ // Unconfined so the receiver's launched coroutine completes before onReceive returns
+ single {
+ CoroutineDispatchers(
+ io = Dispatchers.Unconfined,
+ main = Dispatchers.Unconfined,
+ default = Dispatchers.Unconfined,
+ )
+ }
+ },
+ )
+ }
+ }
+
+ @After
+ fun tearDown() {
+ stopKoin()
+ }
+
+ @Test
+ fun `a thumbs-up from the notification is sent and shown in the conversation`() {
+ val contactKey = "0!12345678"
+ val intent =
+ Intent(ReactionReceiver.REACT_ACTION)
+ .putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
+ .putExtra(ReactionReceiver.EXTRA_REPLY_ID, 42)
+ .putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
+
+ ReactionReceiver().onReceive(ApplicationProvider.getApplicationContext(), intent)
+
+ verifySuspend { radioController.sendReaction("👍", 42, contactKey) }
+ verifySuspend { notificationManager.refreshConversationAfterReply(contactKey) }
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
deleted file mode 100644
index c7512e9c7e..0000000000
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
+++ /dev/null
@@ -1,215 +0,0 @@
-/*
- * 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 android.app.NotificationChannel
-import android.app.PendingIntent
-import android.app.TaskStackBuilder
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
-import androidx.core.content.getSystemService
-import androidx.core.net.toUri
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import org.koin.core.annotation.Single
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
-import org.meshtastic.core.resources.R.drawable
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getStringSuspend
-import org.meshtastic.core.resources.meshtastic_alerts_notifications
-import org.meshtastic.core.resources.meshtastic_low_battery_notifications
-import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications
-import org.meshtastic.core.resources.meshtastic_messages_notifications
-import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
-import org.meshtastic.core.resources.meshtastic_service_notifications
-import org.meshtastic.proto.ClientNotification
-import android.app.NotificationManager as SystemNotificationManager
-
-@Single
-class AndroidNotificationManager(private val context: Context) : NotificationManager {
-
- private val notificationManager =
- checkNotNull(context.getSystemService()) { "NotificationManager not found" }
-
- private data class ChannelConfig(val id: String, val importance: Int)
-
- /**
- * Tracks whether notification channels have been created.
- *
- * Channels are **not** created in the constructor because this singleton is instantiated by Koin during
- * [org.meshtastic.core.service.MeshService.onCreate] on the main thread, and channel names come from string
- * resources. Instead, channels are lazily ensured before the first [dispatch] call. Note that
- * [MeshNotificationManagerImpl.initChannels] already creates a superset of these channels when the orchestrator
- * starts, so this lazy path is only a safety net for notifications dispatched before orchestrator initialization.
- *
- * The mutex is load-bearing: resolving the names suspends, so without it two concurrent [dispatch] calls could both
- * pass the flag check and post before the channels exist.
- */
- private var channelsInitialized = false
- private val channelInitMutex = Mutex()
-
- private suspend fun ensureChannelsInitialized() = channelInitMutex.withLock {
- if (channelsInitialized) return@withLock
- channelsInitialized = true
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val channels =
- listOf(
- createChannel(Notification.Category.Message, Res.string.meshtastic_messages_notifications),
- createChannel(Notification.Category.NodeEvent, Res.string.meshtastic_new_nodes_notifications),
- createChannel(
- Notification.Category.MeshBeacon,
- Res.string.meshtastic_mesh_beacon_notifications,
- ),
- createChannel(Notification.Category.Battery, Res.string.meshtastic_low_battery_notifications),
- createChannel(Notification.Category.Alert, Res.string.meshtastic_alerts_notifications),
- createChannel(Notification.Category.Service, Res.string.meshtastic_service_notifications),
- )
- notificationManager.createNotificationChannels(channels)
- notificationManager.removeLegacyCategoryChannels()
- }
- }
-
- private suspend fun createChannel(
- category: Notification.Category,
- nameRes: org.jetbrains.compose.resources.StringResource,
- ): NotificationChannel {
- val channelConfig = category.channelConfig()
- return NotificationChannel(channelConfig.id, getStringSuspend(nameRes), channelConfig.importance)
- }
-
- // Keep category-to-channel mapping aligned with MeshNotificationManagerImpl.NotificationType IDs.
- private fun Notification.Category.channelConfig(): ChannelConfig = when (this) {
- Notification.Category.Message ->
- ChannelConfig(
- id = NotificationChannels.MESSAGES,
- importance = SystemNotificationManager.IMPORTANCE_HIGH,
- )
-
- Notification.Category.NodeEvent ->
- ChannelConfig(
- id = NotificationChannels.NEW_NODES,
- importance = SystemNotificationManager.IMPORTANCE_DEFAULT,
- )
-
- Notification.Category.MeshBeacon ->
- ChannelConfig(
- id = NotificationChannels.MESH_BEACON,
- importance = SystemNotificationManager.IMPORTANCE_LOW,
- )
-
- Notification.Category.Battery ->
- ChannelConfig(
- id = NotificationChannels.LOW_BATTERY,
- importance = SystemNotificationManager.IMPORTANCE_DEFAULT,
- )
-
- Notification.Category.Alert ->
- ChannelConfig(id = NotificationChannels.ALERTS, importance = SystemNotificationManager.IMPORTANCE_HIGH)
-
- Notification.Category.Service ->
- ChannelConfig(id = NotificationChannels.SERVICE, importance = SystemNotificationManager.IMPORTANCE_MIN)
- }
-
- override suspend fun dispatch(notification: Notification): Boolean = dispatch(notification, onlyAlertOnce = false)
-
- override fun suppressClientNotificationModal(notification: ClientNotification): Boolean =
- notification.isProtectedPositionAdvisory()
-
- // The advisory's id already comes from ClientNotification.notificationId(), which is stable across repeats (see
- // its kdoc) — so it lands in the same tray slot without a dedicated tag or fixed id; only onlyAlertOnce is needed
- // to stop it from re-alerting on every update.
- override suspend fun dispatchClientNotification(
- notification: Notification,
- clientNotification: ClientNotification,
- ): Boolean = dispatch(notification, onlyAlertOnce = clientNotification.isProtectedPositionAdvisory())
-
- private suspend fun dispatch(notification: Notification, onlyAlertOnce: Boolean): Boolean {
- ensureChannelsInitialized()
- val channelId = notification.category.channelConfig().id
- if (!canPostNotifications(channelId)) return false
- val id = notification.id ?: notification.hashCode()
- val builder =
- NotificationCompat.Builder(context, channelId)
- .setContentTitle(notification.title)
- .setContentText(notification.message)
- .setSmallIcon(drawable.meshtastic_ic_notification)
- .setAutoCancel(true)
- .setSilent(notification.isSilent)
-
- notification.group?.let { builder.setGroup(it) }
- if (onlyAlertOnce) builder.setOnlyAlertOnce(true)
-
- if (notification.type == Notification.Type.Error) {
- builder.setPriority(NotificationCompat.PRIORITY_HIGH)
- }
-
- notification.deepLinkUri?.let { uri -> builder.setContentIntent(createDeepLinkPendingIntent(uri, id)) }
-
- return try {
- notificationManager.notify(id, builder.build())
- true
- } catch (_: SecurityException) {
- false
- }
- }
-
- private fun canPostNotifications(channelId: String): Boolean =
- NotificationManagerCompat.from(context).areNotificationsEnabled() &&
- (
- Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
- notificationManager.getNotificationChannel(channelId)?.importance !=
- SystemNotificationManager.IMPORTANCE_NONE
- )
-
- /**
- * Builds a [PendingIntent] that launches [MainActivity] with the given deep-link URI as [Intent.ACTION_VIEW], so
- * the existing deep-link plumbing (`UIViewModel.handleDeepLink` → `DeepLinkRouter` → `MultiBackstack`) can
- * synthesize the proper backstack and surface the target screen.
- *
- * Uses [Class.forName] to avoid pulling the `:androidApp` module into `:core:service` as a Gradle dep.
- */
- private fun createDeepLinkPendingIntent(uri: String, requestCode: Int): PendingIntent {
- val deepLinkIntent =
- Intent(Intent.ACTION_VIEW, uri.toUri(), context, Class.forName(MAIN_ACTIVITY_CLASS)).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
- return TaskStackBuilder.create(context).run {
- addNextIntentWithParentStack(deepLinkIntent)
- getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)!!
- }
- }
-
- override fun cancel(id: Int) {
- notificationManager.cancel(id)
- }
-
- override fun cancelAll() {
- notificationManager.cancelAll()
- }
-
- private companion object {
- /**
- * Fully-qualified name of the host activity that handles `meshtastic://` deep-link intents. Kept as a string to
- * avoid creating a module dependency from `:core:service` back onto `:androidApp`.
- */
- const val MAIN_ACTIVITY_CLASS = "org.meshtastic.app.MainActivity"
- }
-}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt
index 2c8f2a217b..aed4c42e4a 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt
@@ -39,9 +39,8 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.safeCatchingAll
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshPrefs
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.boot_reconnect_blocked_message
import org.meshtastic.core.resources.boot_reconnect_blocked_title
@@ -54,7 +53,7 @@ class BootCompleteReceiver :
private val meshPrefs: MeshPrefs by inject()
private val dispatchers: CoroutineDispatchers by inject()
- private val notificationManager: NotificationManager by inject()
+ private val serviceNotifications: MeshNotificationManager by inject()
private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.default) }
@Suppress("TooGenericExceptionCaught")
@@ -121,8 +120,8 @@ class BootCompleteReceiver :
* Posts the one thing the user can act on: a notification naming the missing permission and opening the Connections
* screen, where the recovery card now lives.
*
- * Best-effort by design. If POST_NOTIFICATIONS is also denied the dispatch simply returns false — there is no
- * surface left to reach an absent user through, and failing loudly here would help nobody.
+ * Best-effort by design. If POST_NOTIFICATIONS is also denied the post simply returns false — there is no surface
+ * left to reach an absent user through, and failing loudly here would help nobody.
*/
private suspend fun notifyBluetoothPermissionMissing() {
// Untranslated fallbacks rather than no notification, and a hard bound on the wait. A boot broadcast runs
@@ -133,16 +132,7 @@ class BootCompleteReceiver :
@Suppress("TooGenericExceptionCaught")
try {
- notificationManager.dispatch(
- Notification(
- title = title,
- message = message,
- type = Notification.Type.Warning,
- category = Notification.Category.Service,
- id = BLE_PERMISSION_NOTIFICATION_ID,
- deepLinkUri = CONNECTIONS_DEEP_LINK,
- ),
- )
+ serviceNotifications.showReconnectBlockedNotification(title, message)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -161,11 +151,6 @@ class BootCompleteReceiver :
/** A broadcast has seconds, not indefinite time; fall back to untranslated text rather than stall. */
const val STRING_RESOLVE_TIMEOUT_MILLIS = 2_000L
- /** Stable id so a second boot replaces the notice rather than stacking another copy. */
- const val BLE_PERMISSION_NOTIFICATION_ID = 0x81E9
-
- const val CONNECTIONS_DEEP_LINK = "meshtastic://meshtastic/connections"
-
const val UNTRANSLATED_BLOCKED_TITLE = "Meshtastic can't reconnect"
const val UNTRANSLATED_BLOCKED_MESSAGE =
"Nearby devices permission is off, so your radio cannot be reached over Bluetooth. " +
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 8ed486ffda..ccca9ef446 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
@@ -17,16 +17,13 @@
package org.meshtastic.core.service
import android.app.Notification
-import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.TaskStackBuilder
-import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
import android.content.Context
import android.content.Intent
-import android.media.AudioAttributes
-import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
@@ -39,39 +36,47 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
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.MetricFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.FirmwareUpdateNotice
+import org.meshtastic.core.model.MeshBeaconOffer
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.formatUptime
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.MeshNotificationManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
+import org.meshtastic.core.repository.notificationId
import org.meshtastic.core.resources.R.drawable
-import org.meshtastic.core.resources.R.raw
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel
-import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.discovery_scan_in_progress
+import org.meshtastic.core.resources.firmware_update_available
import org.meshtastic.core.resources.firmware_update_in_progress
-import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.firmware_update_notification_android
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_battery
@@ -88,17 +93,11 @@ import org.meshtastic.core.resources.local_stats_utilization
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.mark_as_read
-import org.meshtastic.core.resources.meshtastic_alerts_notifications
+import org.meshtastic.core.resources.mesh_beacon_notification_body
+import org.meshtastic.core.resources.mesh_beacon_notification_title
import org.meshtastic.core.resources.meshtastic_app_name
-import org.meshtastic.core.resources.meshtastic_broadcast_notifications
-import org.meshtastic.core.resources.meshtastic_low_battery_notifications
-import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications
-import org.meshtastic.core.resources.meshtastic_messages_notifications
-import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
-import org.meshtastic.core.resources.meshtastic_service_notifications
-import org.meshtastic.core.resources.meshtastic_waypoints_notifications
-import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.core.resources.no_local_stats
+import org.meshtastic.core.resources.notification_reaction_to
import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.unknown_username
@@ -112,6 +111,8 @@ import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import org.meshtastic.core.repository.Notification as MeshNotification
/**
* Manages the creation and display of all app notifications.
@@ -128,6 +129,7 @@ class MeshNotificationManagerImpl(
private val conversationShortcutPublisher: Lazy,
private val radioConfigRepository: Lazy,
private val radioOperationLock: RadioOperationLock,
+ private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
private val scope: ServiceScope,
) : MeshNotificationManager {
@@ -137,9 +139,6 @@ class MeshNotificationManagerImpl(
companion object {
const val MAX_BATTERY_LEVEL = 100
- // Meshtastic brand accent (Green 500, see .skills/design-standards) — used as the notification accent color
- // (small-icon tint) and the notification LED color.
- private val NOTIFICATION_COLOR = 0xFF67EA94.toInt()
private const val MAX_HISTORY_MESSAGES = 10
private const val MIN_CONTEXT_MESSAGES = 3
private const val SNIPPET_LENGTH = 30
@@ -168,6 +167,12 @@ class MeshNotificationManagerImpl(
private const val TAG_NEW_NODE = "new_node"
private const val TAG_LOW_BATTERY = "low_battery"
private const val TAG_CLIENT = "client"
+ private const val TAG_MESH_BEACON = "mesh_beacon"
+ private const val TAG_FIRMWARE_UPDATE = "firmware_update"
+ private const val TAG_RECONNECT_BLOCKED = "reconnect_blocked"
+ private const val RECONNECT_BLOCKED_ID = 1
+ private val CHANNEL_LABEL_TIMEOUT = 2.seconds
+ private const val MAIN_ACTIVITY_CLASS = "org.meshtastic.app.MainActivity"
}
private data class ServiceNotificationSnapshot(
@@ -177,9 +182,11 @@ class MeshNotificationManagerImpl(
val previousMessage: String?,
val nextUpdateAt: Long,
val activeOperations: Set = emptySet(),
+ val firmwareProgress: FirmwareUpdateProgress? = null,
)
- private data class RenderedServiceNotification(val notification: Notification, val message: String)
+ /** [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?)
/**
* Caches generated avatar icons keyed by (person id + short name + colors) so a conversation rebuild does not
@@ -200,169 +207,84 @@ class MeshNotificationManagerImpl(
PersonIconFactory.createLabel(shortName, backgroundColor, foregroundColor, rounded = false)
}
- /**
- * Sealed class to define the properties of each notification channel. This centralizes channel configuration and
- * makes it type-safe.
- */
- private sealed class NotificationType(
- val channelId: String,
- val channelNameRes: StringResource,
- val importance: Int,
- ) {
- object ServiceState :
- NotificationType(
- NotificationChannels.SERVICE,
- Res.string.meshtastic_service_notifications,
- NotificationManager.IMPORTANCE_MIN,
- )
-
- object DirectMessage :
- NotificationType(
- NotificationChannels.MESSAGES,
- Res.string.meshtastic_messages_notifications,
- NotificationManager.IMPORTANCE_HIGH,
- )
-
- object BroadcastMessage :
- NotificationType(
- NotificationChannels.BROADCASTS,
- Res.string.meshtastic_broadcast_notifications,
- NotificationManager.IMPORTANCE_DEFAULT,
- )
-
- object Waypoint :
- NotificationType(
- NotificationChannels.WAYPOINTS,
- Res.string.meshtastic_waypoints_notifications,
- NotificationManager.IMPORTANCE_DEFAULT,
- )
-
- object Alert :
- NotificationType(
- NotificationChannels.ALERTS,
- Res.string.meshtastic_alerts_notifications,
- NotificationManager.IMPORTANCE_HIGH,
- )
-
- object NewNode :
- NotificationType(
- NotificationChannels.NEW_NODES,
- Res.string.meshtastic_new_nodes_notifications,
- NotificationManager.IMPORTANCE_DEFAULT,
- )
-
- object LowBatteryLocal :
- NotificationType(
- NotificationChannels.LOW_BATTERY,
- Res.string.meshtastic_low_battery_notifications,
- NotificationManager.IMPORTANCE_DEFAULT,
- )
-
- object LowBatteryRemote :
- NotificationType(
- NotificationChannels.LOW_BATTERY_REMOTE,
- Res.string.meshtastic_low_battery_temporary_remote_notifications,
- NotificationManager.IMPORTANCE_DEFAULT,
- )
-
- object Client :
- NotificationType(
- NotificationChannels.CLIENT,
- Res.string.client_notification,
- NotificationManager.IMPORTANCE_HIGH,
- )
-
- companion object {
- // A list of all types for easy initialization.
- fun allTypes() = listOf(
- ServiceState,
- DirectMessage,
- BroadcastMessage,
- Waypoint,
- Alert,
- NewNode,
- LowBatteryLocal,
- LowBatteryRemote,
- Client,
- )
- }
- }
-
override fun clearNotifications() {
notificationManager.cancelAll()
}
/**
- * Creates all necessary notification channels on devices running Android O or newer. This should be called once
- * when the service is created.
- *
- * Deliberately blocking (Main-thread, one-time cost): the orchestrator posts the foreground-service notification
- * synchronously right after this returns, so channels must exist before then — do not lazy-gate this into the
- * suspend notify paths. Blocking [getString] is safe here only because Main is not a Dispatchers.Default worker.
+ * Guarantees the foreground-service channel synchronously, because the orchestrator posts the service notification
+ * right after this returns, then creates the rest off the calling thread. Every post awaits [ensureChannels].
*/
override fun initChannels() {
notificationManager.removeLegacyCategoryChannels()
- NotificationType.allTypes().forEach { type -> createNotificationChannel(type) }
+ ensureServiceChannel()
+ scope.launch { ensureChannels() }
}
- private fun createNotificationChannel(type: NotificationType) {
- if (notificationManager.getNotificationChannel(type.channelId) != null) return
+ /** Creates the service channel under the app label if it is missing; [ensureChannels] later gives it its name. */
+ private fun ensureServiceChannel() {
+ if (notificationManager.getNotificationChannel(NotificationChannelSpec.Service.id) != null) return
+ // The platform throws for a channel whose group does not exist yet, and this runs before ensureChannels.
+ notificationManager.createNotificationChannelGroup(
+ NotificationChannelSpec.Service.group.toGroup(applicationLabel),
+ )
+ notificationManager.createNotificationChannel(
+ NotificationChannelSpec.Service.toChannel(context, name = applicationLabel, description = ""),
+ )
+ }
- val channelName = getString(type.channelNameRes)
- val channel =
- NotificationChannel(type.channelId, channelName, type.importance).apply {
- lightColor = NOTIFICATION_COLOR
- lockscreenVisibility = Notification.VISIBILITY_PUBLIC // Default, can be overridden
+ private val channelsMutex = Mutex()
+ private var channelsReady = false
- // Type-specific configurations
- when (type) {
- NotificationType.ServiceState -> {
- lockscreenVisibility = Notification.VISIBILITY_PRIVATE
- }
+ private class ChannelLabels(
+ val groups: Map,
+ val names: Map,
+ val descriptions: Map,
+ )
- NotificationType.DirectMessage,
- NotificationType.BroadcastMessage,
- NotificationType.Waypoint,
- NotificationType.NewNode,
- NotificationType.LowBatteryLocal,
- NotificationType.LowBatteryRemote,
- -> {
- setShowBadge(true)
- setSound(
- RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
- AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_NOTIFICATION)
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
- .build(),
- )
- if (type == NotificationType.LowBatteryRemote) enableVibration(true)
- }
+ private suspend fun resolveChannelLabels() = ChannelLabels(
+ groups = NotificationChannelGroupSpec.entries.associateWith { getStringSuspend(it.nameRes) },
+ names = NotificationChannelSpec.entries.associateWith { getStringSuspend(it.nameRes) },
+ descriptions = NotificationChannelSpec.entries.associateWith { getStringSuspend(it.descriptionRes) },
+ )
- NotificationType.Alert -> {
- setShowBadge(true)
- enableLights(true)
- enableVibration(true)
- setBypassDnd(true)
- val alertSoundUri =
- "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.meshtastic_alert}".toUri()
- setSound(
- alertSoundUri,
- AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_ALARM) // More appropriate for an alert
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
- .build(),
- )
- }
-
- NotificationType.Client -> {
- setShowBadge(true)
- }
- }
+ /**
+ * Creates every channel group and channel, once per process. Re-creating an existing channel only refreshes its
+ * name, description and (if it had none) group, so this also carries a locale change and the grouping onto installs
+ * whose channels predate them.
+ *
+ * Label loading is bounded: a boot broadcast posts through here with seconds to live, and a channel under the app
+ * label beats no notification. Such a pass does not count as done, so the next post puts the real labels on.
+ */
+ internal suspend fun ensureChannels() = channelsMutex.withLock {
+ if (channelsReady) return@withLock
+ val labels =
+ withTimeoutOrNull(CHANNEL_LABEL_TIMEOUT) { safeCatching { resolveChannelLabels() }.getOrNull() }
+ val groups =
+ NotificationChannelGroupSpec.entries.map { it.toGroup(labels?.groups?.get(it) ?: applicationLabel) }
+ notificationManager.createNotificationChannelGroups(groups)
+ val channels =
+ NotificationChannelSpec.entries.map { spec ->
+ spec.toChannel(
+ context,
+ name = labels?.names?.get(spec) ?: applicationLabel,
+ description = labels?.descriptions?.get(spec).orEmpty(),
+ )
}
- notificationManager.createNotificationChannel(channel)
+ notificationManager.createNotificationChannels(channels)
+ channelsReady = labels != null
}
+ /** Whether a post on [spec] would reach the user: app notifications on, and the channel not blocked. */
+ private fun canPost(spec: NotificationChannelSpec): Boolean =
+ NotificationManagerCompat.from(context).areNotificationsEnabled() &&
+ notificationManager.getNotificationChannel(spec.id)?.importance != NotificationManager.IMPORTANCE_NONE
+
private val serviceNotificationLock = Any()
+ private val lowBatteryLock = Any()
+
+ /** Per node, how many times its low-battery warning was cancelled; guarded by [lowBatteryLock]. */
+ private val lowBatteryCancellations = mutableMapOf()
private val applicationLabel: String by lazy {
context.applicationInfo.loadLabel(context.packageManager).toString().ifBlank { context.packageName }
}
@@ -386,6 +308,14 @@ class MeshNotificationManagerImpl(
}
}
}
+ scope.launch {
+ firmwareUpdateStatusRepository.progress.collect { progress ->
+ synchronized(serviceNotificationLock) {
+ val last = serviceNotificationSnapshots.replayCache.lastOrNull() ?: return@synchronized
+ serviceNotificationSnapshots.tryEmit(last.copy(firmwareProgress = progress))
+ }
+ }
+ }
}
/**
@@ -394,6 +324,7 @@ class MeshNotificationManagerImpl(
* Multiplatform resource loading.
*/
fun getServiceNotification(): Notification {
+ ensureServiceChannel()
val cached = synchronized(serviceNotificationLock) { cachedServiceNotification }
return cached ?: createServiceStateNotification(name = applicationLabel, message = null, nextUpdateAt = 0)
}
@@ -468,10 +399,20 @@ class MeshNotificationManagerImpl(
previousMessage = cachedMessage,
nextUpdateAt = nextStatsUpdateMillis,
activeOperations = radioOperationLock.activeOperations,
+ firmwareProgress = firmwareUpdateStatusRepository.progress.value,
)
}
private suspend fun renderServiceNotification(snapshot: ServiceNotificationSnapshot): RenderedServiceNotification {
+ snapshot.firmwareProgress?.let { progress ->
+ val notification =
+ createFirmwareProgressNotification(
+ title = getStringSuspend(Res.string.firmware_update_in_progress),
+ text = progress.message.resolve(),
+ percent = progress.percent,
+ )
+ return RenderedServiceNotification(notification, message = snapshot.previousMessage)
+ }
// A held operation outranks the connection state. During a firmware update the device is deliberately
// deselected, so the state alone would report "Disconnected" over a flash that is running perfectly.
val title =
@@ -532,6 +473,7 @@ class MeshNotificationManagerImpl(
waypointId: Int,
isSilent: Boolean,
) {
+ ensureChannels()
val notification = createWaypointNotification(name, message, waypointId, isSilent)
notificationManager.notify(TAG_WAYPOINT, contactKey.hashCode(), notification)
}
@@ -543,6 +485,7 @@ class MeshNotificationManagerImpl(
conversationName: String,
isSilent: Boolean = false,
) {
+ ensureChannels()
// Publish (or refresh) a long-lived conversation shortcut before the notification references it, so Android can
// rank the notification in the shade's Conversations section and expose it to Android Auto/Wear. A channel name
// labels a broadcast conversation; a direct message is labelled by the other participant's name.
@@ -645,7 +588,7 @@ class MeshNotificationManagerImpl(
}
val summaryNotification =
- commonBuilder(NotificationType.DirectMessage)
+ commonBuilder(NotificationChannelSpec.DirectMessages)
.setSmallIcon(drawable.meshtastic_ic_notification)
.setStyle(messagingStyle)
.setGroup(GROUP_KEY_MESSAGES)
@@ -659,26 +602,125 @@ class MeshNotificationManagerImpl(
notificationManager.notify(TAG_MESSAGE_SUMMARY, SUMMARY_ID, summaryNotification)
}
- override fun showAlertNotification(contactKey: String, name: String, alert: String) {
- val notification = createAlertNotification(contactKey, name, alert)
- // Use a consistent, unique ID for each alert source.
- notificationManager.notify(TAG_ALERT, name.hashCode(), notification)
+ override suspend fun showAlertNotification(contactKey: String, name: String, alert: String) {
+ ensureChannels()
+ notificationManager.notify(TAG_ALERT, contactKey.hashCode(), createAlertNotification(contactKey, name, alert))
}
- override fun showNewNodeSeenNotification(node: Node) {
- val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num)
- notificationManager.notify(TAG_NEW_NODE, node.num, notification)
- }
-
- override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
- val notification = createLowBatteryNotification(node, isRemote)
- notificationManager.notify(TAG_LOW_BATTERY, node.num, notification)
- }
-
- override fun showClientNotification(clientNotification: ClientNotification) {
+ override suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer) {
+ ensureChannels()
+ val title = getStringSuspend(Res.string.mesh_beacon_notification_title)
+ val message = offer.message.ifBlank { getStringSuspend(Res.string.mesh_beacon_notification_body) }
val notification =
- createClientNotification(getString(Res.string.client_notification), clientNotification.message)
- notificationManager.notify(TAG_CLIENT, clientNotification.toString().hashCode(), notification)
+ commonBuilder(NotificationChannelSpec.MeshBeacon, createDeepLinkIntent("discovery", offer.fromNodeNum))
+ .setCategory(Notification.CATEGORY_RECOMMENDATION)
+ .setAutoCancel(true)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ .build()
+ notificationManager.notify(TAG_MESH_BEACON, offer.fromNodeNum, notification)
+ }
+
+ override suspend fun showNewNodeSeenNotification(node: Node, title: String) {
+ ensureChannels()
+ notificationManager.notify(TAG_NEW_NODE, node.num, createNewNodeSeenNotification(title, node))
+ }
+
+ override fun cancelNewNodeNotification(nodeNum: Int) = notificationManager.cancel(TAG_NEW_NODE, nodeNum)
+
+ override suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean) =
+ postLowBattery(node, isRemote, onlyIfShowing = false)
+
+ override suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean) =
+ postLowBattery(node, isRemote, onlyIfShowing = true)
+
+ /**
+ * Recovery cancels from separate work, so a post that was already resolving its text when the battery recovered
+ * must not land afterwards: it notes the node's cancellation count before building and posts only if it is
+ * unchanged.
+ */
+ private suspend fun postLowBattery(node: Node, isRemote: Boolean, onlyIfShowing: Boolean) {
+ val cancellationsBefore = synchronized(lowBatteryLock) { lowBatteryCancellations[node.num] ?: 0 }
+ ensureChannels()
+ val notification = createLowBatteryNotification(node, isRemote)
+ beforeLowBatteryPost?.invoke()
+ synchronized(lowBatteryLock) {
+ if ((lowBatteryCancellations[node.num] ?: 0) != cancellationsBefore) return
+ val showing = notificationManager.activeNotifications.any { it.tag == TAG_LOW_BATTERY && it.id == node.num }
+ if (!onlyIfShowing || showing) notificationManager.notify(TAG_LOW_BATTERY, node.num, notification)
+ }
+ }
+
+ /** Test seam run between building a low-battery notification and posting it, to race a recovery in. */
+ internal var beforeLowBatteryPost: (() -> Unit)? = null
+
+ override suspend fun showClientNotification(
+ clientNotification: ClientNotification,
+ title: String,
+ severity: MeshNotification.Type,
+ ) {
+ ensureChannels()
+ val message = clientNotification.message
+ val warning = severity == MeshNotification.Type.Warning || severity == MeshNotification.Type.Error
+ val notification =
+ commonBuilder(NotificationChannelSpec.Client)
+ .setCategory(if (warning) Notification.CATEGORY_ERROR else Notification.CATEGORY_STATUS)
+ .setAutoCancel(true)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ // The firmware repeats this advisory on every position request; the tray slot is stable across
+ // repeats (see notificationId), so only the first one should sound.
+ .setOnlyAlertOnce(clientNotification.isProtectedPositionAdvisory())
+ .build()
+ notificationManager.notify(TAG_CLIENT, clientNotification.notificationId(), notification)
+ }
+
+ override fun clearClientNotification(clientNotification: ClientNotification) =
+ notificationManager.cancel(TAG_CLIENT, clientNotification.notificationId())
+
+ override fun suppressClientNotificationModal(clientNotification: ClientNotification): Boolean =
+ clientNotification.isProtectedPositionAdvisory()
+
+ override suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean {
+ ensureChannels()
+ if (!canPost(NotificationChannelSpec.DeviceStatus)) return false
+ val message =
+ getStringSuspend(
+ Res.string.firmware_update_notification_android,
+ notice.currentVersion,
+ notice.stableVersion,
+ )
+ val id = notice.notificationKey.hashCode()
+ val notification =
+ commonBuilder(NotificationChannelSpec.DeviceStatus, createDeepLinkIntent("firmware/update", id))
+ .setCategory(Notification.CATEGORY_RECOMMENDATION)
+ .setAutoCancel(true)
+ .setContentTitle(getStringSuspend(Res.string.firmware_update_available))
+ .setContentText(message)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ .build()
+ notificationManager.notify(TAG_FIRMWARE_UPDATE, id, notification)
+ return true
+ }
+
+ override suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean {
+ ensureChannels()
+ if (!canPost(NotificationChannelSpec.DeviceStatus)) return false
+ val notification =
+ commonBuilder(
+ NotificationChannelSpec.DeviceStatus,
+ createDeepLinkIntent("connections", RECONNECT_BLOCKED_ID),
+ )
+ .setCategory(Notification.CATEGORY_ERROR)
+ .setAutoCancel(true)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ .build()
+ notificationManager.notify(TAG_RECONNECT_BLOCKED, RECONNECT_BLOCKED_ID, notification)
+ return true
}
override suspend fun cancelMessageNotification(contactKey: String) {
@@ -716,18 +758,17 @@ class MeshNotificationManagerImpl(
showConversationNotification(contactKey, isBroadcast, channelName, conversationName, isSilent = true)
}
- override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(TAG_LOW_BATTERY, node.num)
-
- override fun clearClientNotification(notification: ClientNotification) =
- notificationManager.cancel(TAG_CLIENT, notification.toString().hashCode())
+ override fun cancelLowBatteryNotification(node: Node) = synchronized(lowBatteryLock) {
+ lowBatteryCancellations[node.num] = (lowBatteryCancellations[node.num] ?: 0) + 1
+ notificationManager.cancel(TAG_LOW_BATTERY, node.num)
+ }
// endregion
// region Notification Creation
private fun createServiceStateNotification(name: String, message: String?, nextUpdateAt: Long?): Notification {
val builder =
- commonBuilder(NotificationType.ServiceState)
- .setPriority(NotificationCompat.PRIORITY_MIN)
+ commonBuilder(NotificationChannelSpec.Service)
.setCategory(Notification.CATEGORY_SERVICE)
.setOngoing(true)
// Android 12+ may defer FGS notifications ~10s; show immediately so the user watching a
@@ -753,6 +794,27 @@ class MeshNotificationManagerImpl(
return builder.build()
}
+ /**
+ * A flash is a start-to-end journey the user may background, so the service notification asks to be promoted to a
+ * Live Update while one runs. The platform grants it only on a channel above MIN and with
+ * POST_PROMOTED_NOTIFICATIONS; elsewhere this is an ordinary ongoing progress notification.
+ */
+ private fun createFirmwareProgressNotification(title: String, text: String, percent: Int?): Notification {
+ val style = NotificationCompat.ProgressStyle().setProgressIndeterminate(percent == null)
+ percent?.let { style.setProgress(it) }
+ return commonBuilder(NotificationChannelSpec.Service)
+ .setCategory(Notification.CATEGORY_PROGRESS)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setStyle(style)
+ .setRequestPromotedOngoing(true)
+ .apply { percent?.let { setShortCriticalText(MetricFormatter.percent(it)) } }
+ .build()
+ }
+
@Suppress("LongMethod")
private suspend fun createConversationNotification(
contactKey: String,
@@ -761,7 +823,7 @@ class MeshNotificationManagerImpl(
history: List,
isSilent: Boolean = false,
): Notification {
- val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage
+ val type = if (isBroadcast) NotificationChannelSpec.Broadcasts else NotificationChannelSpec.DirectMessages
val builder = commonBuilder(type, createOpenMessageIntent(contactKey))
if (isSilent) {
@@ -813,7 +875,7 @@ class MeshNotificationManagerImpl(
val text =
msg.originalMessage?.let { original ->
- "↩️ \"${original.node.user.short_name}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
+ "↩️ \"${original.node.user.short_name}: ${snippet(original.text)}\": ${msg.text}"
} ?: msg.text
if (msg.read && (anyUnread || index != lastIndex)) {
@@ -839,7 +901,7 @@ class MeshNotificationManagerImpl(
)
.build()
style.addMessage(
- "${reaction.emoji} to \"${msg.text.take(SNIPPET_LENGTH)}...\"",
+ getStringSuspend(Res.string.notification_reaction_to, reaction.emoji, snippet(msg.text)),
reaction.timestamp,
reactor,
)
@@ -901,11 +963,13 @@ class MeshNotificationManagerImpl(
val style = NotificationCompat.MessagingStyle(person).addMessage(message, nowMillis, person)
val builder =
- commonBuilder(NotificationType.Waypoint, createOpenWaypointIntent(waypointId))
+ commonBuilder(
+ NotificationChannelSpec.Waypoints,
+ createDeepLinkIntent("map?waypointId=$waypointId", waypointId),
+ )
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setStyle(style)
- .setGroup(GROUP_KEY_MESSAGES)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(nowMillis)
.setShowWhen(true)
@@ -921,38 +985,36 @@ class MeshNotificationManagerImpl(
val person = Person.Builder().setName(name).build()
val style = NotificationCompat.MessagingStyle(person).addMessage(alert, nowMillis, person)
- return commonBuilder(NotificationType.Alert, createOpenMessageIntent(contactKey))
- .setPriority(NotificationCompat.PRIORITY_HIGH)
+ return commonBuilder(NotificationChannelSpec.Alerts, createOpenMessageIntent(contactKey))
.setCategory(Notification.CATEGORY_ALARM)
.setAutoCancel(true)
.setStyle(style)
.build()
}
- private fun createNewNodeSeenNotification(name: String, message: String, nodeNum: Int): Notification {
- val title = getString(Res.string.new_node_seen, name)
- val builder =
- commonBuilder(NotificationType.NewNode, createOpenNodeDetailIntent(nodeNum))
- .setCategory(Notification.CATEGORY_STATUS)
- .setAutoCancel(true)
- .setContentTitle(title)
- .setWhen(nowMillis)
- .setShowWhen(true)
- .setContentText(message)
- .setStyle(NotificationCompat.BigTextStyle().bigText(message))
-
- return builder.build()
+ private fun createNewNodeSeenNotification(title: String, node: Node): Notification {
+ val message = node.user.long_name
+ return commonBuilder(NotificationChannelSpec.NewNodes, createOpenNodeDetailIntent(node.num))
+ .setCategory(Notification.CATEGORY_STATUS)
+ .setAutoCancel(true)
+ .setContentTitle(title)
+ .setWhen(nowMillis)
+ .setShowWhen(true)
+ .setContentText(message)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ .build()
}
- private fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification {
- val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
- val title = getString(Res.string.low_battery_title, node.user.short_name)
+ private suspend fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification {
+ val type = if (isRemote) NotificationChannelSpec.LowBatteryRemote else NotificationChannelSpec.LowBattery
+ val title = getStringSuspend(Res.string.low_battery_title, node.user.short_name)
val batteryLevel = node.deviceMetrics.battery_level ?: 0
- val message = getString(Res.string.low_battery_message, node.user.long_name, batteryLevel)
+ val message = getStringSuspend(Res.string.low_battery_message, node.user.long_name, batteryLevel)
+ // Not ongoing: an ongoing notification never bridges to a watch, and recovery cancels this one anyway.
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
.setCategory(Notification.CATEGORY_STATUS)
- .setOngoing(true)
+ .setAutoCancel(true)
.setOnlyAlertOnce(true)
.setProgress(MAX_BATTERY_LEVEL, batteryLevel, false)
.setContentTitle(title)
@@ -963,39 +1025,38 @@ class MeshNotificationManagerImpl(
.build()
}
- private fun createClientNotification(name: String, message: String): Notification =
- commonBuilder(NotificationType.Client)
- .setCategory(Notification.CATEGORY_ERROR)
- .setAutoCancel(true)
- .setContentTitle(name)
- .setContentText(message)
- .setStyle(NotificationCompat.BigTextStyle().bigText(message))
- .build()
-
// endregion
// region Helper/Builder Methods
private val openAppIntent: PendingIntent by lazy {
val intent =
- Intent(context, Class.forName("org.meshtastic.app.MainActivity")).apply {
+ Intent(context, Class.forName(MAIN_ACTIVITY_CLASS)).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
- private fun createOpenMessageIntent(contactKey: String): PendingIntent {
- val deepLinkUri = "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()
+ /**
+ * Opens `meshtastic://meshtastic/[path]` in MainActivity, whose deep-link router synthesizes the backstack. Each
+ * link is its own PendingIntent because the URI takes part in intent equality; [requestCode] only has to be stable.
+ */
+ private fun createDeepLinkIntent(path: String, requestCode: Int): PendingIntent {
val deepLinkIntent =
- Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
-
+ Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/$path".toUri(), context, Class.forName(MAIN_ACTIVITY_CLASS))
+ .apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(deepLinkIntent)
- getPendingIntent(contactKey.hashCode(), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ checkNotNull(
+ getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT),
+ )
}
}
+ private fun createOpenMessageIntent(contactKey: String) =
+ createDeepLinkIntent("messages/$contactKey", contactKey.hashCode())
+
+ private fun createOpenNodeDetailIntent(nodeNum: Int) = createDeepLinkIntent("nodes/$nodeNum", nodeNum)
+
/**
* Bubble target: [org.meshtastic.app.BubbleActivity], not the launcher activity. A bubble's activity must be
* resizeable, embeddable and document-launched, and making the launcher activity document-launched would change how
@@ -1027,32 +1088,6 @@ class MeshNotificationManagerImpl(
.build()
}
- private fun createOpenWaypointIntent(waypointId: Int): PendingIntent {
- val deepLinkUri = "$DEEP_LINK_BASE_URI/map?waypointId=$waypointId".toUri()
- val deepLinkIntent =
- Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
-
- return TaskStackBuilder.create(context).run {
- addNextIntentWithParentStack(deepLinkIntent)
- getPendingIntent(waypointId, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
- }
- }
-
- private fun createOpenNodeDetailIntent(nodeNum: Int): PendingIntent {
- val deepLinkUri = "$DEEP_LINK_BASE_URI/nodes/$nodeNum".toUri()
- val deepLinkIntent =
- Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
-
- return TaskStackBuilder.create(context).run {
- addNextIntentWithParentStack(deepLinkIntent)
- getPendingIntent(nodeNum, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
- }
- }
-
private suspend fun createReplyAction(contactKey: String): NotificationCompat.Action {
val replyLabel = getStringSuspend(Res.string.reply)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build()
@@ -1075,6 +1110,8 @@ class MeshNotificationManagerImpl(
// Required for Android Auto to drive reply hands-free without opening any UI.
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
+ // Offers Smart Reply suggestions when the notification is bridged to a Wear OS watch.
+ .setAllowGeneratedReplies(true)
.build()
}
@@ -1124,16 +1161,22 @@ class MeshNotificationManagerImpl(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent).build()
+ return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_THUMBS_UP)
+ .setShowsUserInterface(false)
+ .build()
}
+ private fun snippet(text: String): String =
+ if (text.length > SNIPPET_LENGTH) text.take(SNIPPET_LENGTH).trimEnd() + "…" else text
+
private fun commonBuilder(
- type: NotificationType,
+ type: NotificationChannelSpec,
contentIntent: PendingIntent? = null,
): NotificationCompat.Builder {
val smallIcon = drawable.meshtastic_ic_notification
- return NotificationCompat.Builder(context, type.channelId)
+ return NotificationCompat.Builder(context, type.id)
.setSmallIcon(smallIcon)
.setColor(NOTIFICATION_COLOR)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannelSpec.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannelSpec.kt
new file mode 100644
index 0000000000..b37a57f281
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannelSpec.kt
@@ -0,0 +1,206 @@
+/*
+ * 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 android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationChannelGroup
+import android.app.NotificationManager
+import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
+import android.content.Context
+import android.media.AudioAttributes
+import android.media.RingtoneManager
+import androidx.core.net.toUri
+import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.core.resources.R.raw
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.device
+import org.meshtastic.core.resources.meshtastic_alerts_notifications
+import org.meshtastic.core.resources.meshtastic_alerts_notifications_description
+import org.meshtastic.core.resources.meshtastic_broadcast_notifications
+import org.meshtastic.core.resources.meshtastic_broadcast_notifications_description
+import org.meshtastic.core.resources.meshtastic_client_notifications
+import org.meshtastic.core.resources.meshtastic_client_notifications_description
+import org.meshtastic.core.resources.meshtastic_device_status_notifications
+import org.meshtastic.core.resources.meshtastic_device_status_notifications_description
+import org.meshtastic.core.resources.meshtastic_low_battery_notifications
+import org.meshtastic.core.resources.meshtastic_low_battery_notifications_description
+import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications
+import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications_description
+import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications
+import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications_description
+import org.meshtastic.core.resources.meshtastic_messages_notifications
+import org.meshtastic.core.resources.meshtastic_messages_notifications_description
+import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
+import org.meshtastic.core.resources.meshtastic_new_nodes_notifications_description
+import org.meshtastic.core.resources.meshtastic_service_notifications
+import org.meshtastic.core.resources.meshtastic_service_notifications_description
+import org.meshtastic.core.resources.meshtastic_waypoints_notifications
+import org.meshtastic.core.resources.meshtastic_waypoints_notifications_description
+import org.meshtastic.core.resources.messages
+import org.meshtastic.core.resources.notification_group_mesh
+
+/** Meshtastic brand accent (Green 500, see .skills/design-standards): the small-icon tint and the LED colour. */
+internal val NOTIFICATION_COLOR = 0xFF67EA94.toInt()
+
+/**
+ * Every notification channel the app owns. The platform lets an app change a channel's name, description and (once)
+ * group after creation; importance, sound, vibration and lights are fixed the first time the channel is created, so an
+ * importance here must never drop below what an earlier release created for the same id.
+ */
+internal enum class NotificationChannelSpec(
+ val id: String,
+ val nameRes: StringResource,
+ val descriptionRes: StringResource,
+ val group: NotificationChannelGroupSpec,
+ val importance: Int,
+) {
+ // A foreground-service channel below LOW is raised to LOW by the platform on first use anyway.
+ Service(
+ NotificationChannels.SERVICE,
+ Res.string.meshtastic_service_notifications,
+ Res.string.meshtastic_service_notifications_description,
+ NotificationChannelGroupSpec.Device,
+ NotificationManager.IMPORTANCE_LOW,
+ ),
+ DirectMessages(
+ NotificationChannels.MESSAGES,
+ Res.string.meshtastic_messages_notifications,
+ Res.string.meshtastic_messages_notifications_description,
+ NotificationChannelGroupSpec.Messages,
+ NotificationManager.IMPORTANCE_HIGH,
+ ),
+ Broadcasts(
+ NotificationChannels.BROADCASTS,
+ Res.string.meshtastic_broadcast_notifications,
+ Res.string.meshtastic_broadcast_notifications_description,
+ NotificationChannelGroupSpec.Messages,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ Waypoints(
+ NotificationChannels.WAYPOINTS,
+ Res.string.meshtastic_waypoints_notifications,
+ Res.string.meshtastic_waypoints_notifications_description,
+ NotificationChannelGroupSpec.Messages,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ Alerts(
+ NotificationChannels.ALERTS,
+ Res.string.meshtastic_alerts_notifications,
+ Res.string.meshtastic_alerts_notifications_description,
+ NotificationChannelGroupSpec.Messages,
+ NotificationManager.IMPORTANCE_HIGH,
+ ),
+ NewNodes(
+ NotificationChannels.NEW_NODES,
+ Res.string.meshtastic_new_nodes_notifications,
+ Res.string.meshtastic_new_nodes_notifications_description,
+ NotificationChannelGroupSpec.Mesh,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ MeshBeacon(
+ NotificationChannels.MESH_BEACON,
+ Res.string.meshtastic_mesh_beacon_notifications,
+ Res.string.meshtastic_mesh_beacon_notifications_description,
+ NotificationChannelGroupSpec.Mesh,
+ NotificationManager.IMPORTANCE_LOW,
+ ),
+ LowBatteryRemote(
+ NotificationChannels.LOW_BATTERY_REMOTE,
+ Res.string.meshtastic_low_battery_temporary_remote_notifications,
+ Res.string.meshtastic_low_battery_temporary_remote_notifications_description,
+ NotificationChannelGroupSpec.Mesh,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ LowBattery(
+ NotificationChannels.LOW_BATTERY,
+ Res.string.meshtastic_low_battery_notifications,
+ Res.string.meshtastic_low_battery_notifications_description,
+ NotificationChannelGroupSpec.Device,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ Client(
+ NotificationChannels.CLIENT,
+ Res.string.meshtastic_client_notifications,
+ Res.string.meshtastic_client_notifications_description,
+ NotificationChannelGroupSpec.Device,
+ NotificationManager.IMPORTANCE_HIGH,
+ ),
+ DeviceStatus(
+ NotificationChannels.DEVICE_STATUS,
+ Res.string.meshtastic_device_status_notifications,
+ Res.string.meshtastic_device_status_notifications_description,
+ NotificationChannelGroupSpec.Device,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ ;
+
+ fun toChannel(context: Context, name: String, description: String): NotificationChannel =
+ NotificationChannel(id, name, importance).also { channel ->
+ channel.description = description
+ channel.group = group.id
+ channel.lightColor = NOTIFICATION_COLOR
+ channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+ channel.setShowBadge(true)
+ when (this) {
+ Service -> {
+ channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ channel.setShowBadge(false)
+ }
+
+ Alerts -> {
+ channel.enableLights(true)
+ channel.enableVibration(true)
+ val alertSound = "$SCHEME_ANDROID_RESOURCE://${context.packageName}/${raw.meshtastic_alert}".toUri()
+ channel.setSound(alertSound, soundAttributes(AudioAttributes.USAGE_ALARM))
+ }
+
+ LowBatteryRemote -> {
+ channel.enableVibration(true)
+ channel.setSound(defaultSound, soundAttributes(AudioAttributes.USAGE_NOTIFICATION))
+ }
+
+ DirectMessages,
+ Broadcasts,
+ Waypoints,
+ NewNodes,
+ LowBattery,
+ DeviceStatus,
+ -> channel.setSound(defaultSound, soundAttributes(AudioAttributes.USAGE_NOTIFICATION))
+
+ MeshBeacon,
+ Client,
+ -> Unit
+ }
+ }
+
+ private companion object {
+ val defaultSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
+
+ fun soundAttributes(usage: Int): AudioAttributes =
+ AudioAttributes.Builder().setUsage(usage).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build()
+ }
+}
+
+internal enum class NotificationChannelGroupSpec(val id: String, val nameRes: StringResource) {
+ Messages("group_messages", Res.string.messages),
+ Mesh("group_mesh", Res.string.notification_group_mesh),
+ Device("group_device", Res.string.device),
+ ;
+
+ fun toGroup(name: String): NotificationChannelGroup = NotificationChannelGroup(id, name)
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt
index bf1057865e..3830648cca 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt
@@ -27,6 +27,7 @@ object NotificationChannels {
const val LOW_BATTERY = "low_battery"
const val LOW_BATTERY_REMOTE = "low_battery_remote"
const val CLIENT = "client_notifications"
+ const val DEVICE_STATUS = "device_status"
// Legacy enum-name channel IDs introduced by alpha channel routing.
val LEGACY_CATEGORY_IDS = listOf("Message", "NodeEvent", "Battery", "Alert", "Service")
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
index 433fdb7e0f..9d1f599d96 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
@@ -25,7 +25,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.RadioController
import kotlin.coroutines.cancellation.CancellationException
@@ -40,6 +42,7 @@ class ReactionReceiver :
KoinComponent {
private val radioController: RadioController by inject()
+ private val meshServiceNotifications: MeshNotificationManager by inject()
private val dispatchers: CoroutineDispatchers by inject()
@@ -53,16 +56,20 @@ class ReactionReceiver :
val reaction = intent.getStringExtra(EXTRA_EMOJI) ?: intent.getStringExtra(EXTRA_REACTION) ?: return
val replyId = intent.getIntExtra(EXTRA_REPLY_ID, intent.getIntExtra(EXTRA_PACKET_ID, 0))
- val pendingResult = goAsync()
+ val pendingResult: PendingResult? = goAsync()
scope.launch {
try {
radioController.sendReaction(reaction, replyId, contactKey)
+ // The action opens no UI, so re-posting the conversation with the reaction is the only feedback the
+ // shade or a watch gets that the tap worked.
+ safeCatching { meshServiceNotifications.refreshConversationAfterReply(contactKey) }
+ .onFailure { Logger.e(it) { "Refresh after reaction failed" } }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.e(e) { "Error sending reaction" }
} finally {
- pendingResult.finish()
+ pendingResult?.finish()
}
}
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt
index 356c312c28..3c38fa6776 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt
@@ -27,12 +27,12 @@ import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MessagingController
import org.meshtastic.core.repository.NodeController
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.QueryController
@@ -74,7 +74,7 @@ class CoreServiceAndroidModule {
meshPrefs: MeshPrefs,
uiPrefs: UiPrefs,
databaseManager: DatabaseManager,
- notificationManager: NotificationManager,
+ serviceNotifications: MeshNotificationManager,
messageProcessor: Lazy,
radioConfigRepository: RadioConfigRepository,
scope: ServiceScope,
@@ -91,7 +91,7 @@ class CoreServiceAndroidModule {
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
- notificationManager = notificationManager,
+ serviceNotifications = serviceNotifications,
messageProcessor = messageProcessor,
radioConfigRepository = radioConfigRepository,
scope = scope,
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
index 413d0f43bc..82cc24204b 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
@@ -41,12 +41,12 @@ import org.meshtastic.core.repository.ConnectionIdentity
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MessagingController
import org.meshtastic.core.repository.NodeController
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.QueryController
@@ -109,7 +109,7 @@ class RadioControllerImpl(
private val meshPrefs: MeshPrefs,
uiPrefs: UiPrefs,
private val databaseManager: DatabaseManager,
- private val notificationManager: NotificationManager,
+ private val serviceNotifications: MeshNotificationManager,
private val messageProcessor: Lazy,
radioConfigRepository: RadioConfigRepository,
scope: CoroutineScope,
@@ -321,7 +321,7 @@ class RadioControllerImpl(
messageProcessor.value.clearEarlyPackets()
databaseManager.switchActiveDatabase(deviceAddr)
nodeManager.clear()
- notificationManager.cancelAll()
+ serviceNotifications.clearNotifications()
nodeManager.loadCachedNodeDB()
// Commit the persisted selection last. MeshPrefs writes asynchronously, so the transport's synchronous
// selected-address snapshot remains the rollback authority for a rapid subsequent selection.
@@ -349,7 +349,7 @@ class RadioControllerImpl(
attemptRollback("fail-closed connection-identity clear") { nodeManager.clearConnectionIdentity() }
attemptRollback("fail-closed node-state clear") { nodeManager.clear() }
attemptRollback("fail-closed early-packet clear") { messageProcessor.value.clearEarlyPackets() }
- attemptRollback("fail-closed notification clear") { notificationManager.cancelAll() }
+ attemptRollback("fail-closed notification clear") { serviceNotifications.clearNotifications() }
attemptRollback("fail-closed persisted selection") { meshPrefs.setDeviceAddress(null) }
attemptRollback("fail-closed transport selection") {
check(radioInterfaceService.setDeviceAddress(null)) { "Transport rejected fail-closed deselection" }
@@ -362,7 +362,7 @@ class RadioControllerImpl(
nodeManager.clearConnectionIdentity()
nodeManager.clear()
messageProcessor.value.clearEarlyPackets()
- notificationManager.cancelAll()
+ serviceNotifications.clearNotifications()
nodeManager.loadCachedNodeDB()
}
attemptRollback("transport selection") {
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
index f70f191196..2d8cd06746 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
@@ -58,10 +58,10 @@ import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
@@ -104,7 +104,7 @@ class RadioControllerImplTest {
private val meshPrefs: MeshPrefs = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
- private val notificationManager: NotificationManager = mock(MockMode.autofill)
+ private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
@@ -173,7 +173,7 @@ class RadioControllerImplTest {
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
- notificationManager = notificationManager,
+ serviceNotifications = serviceNotifications,
messageProcessor = lazy { messageProcessor },
radioConfigRepository = radioConfigRepository,
scope = scope,
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt
index 7b7a83171b..f2cd21eca6 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt
@@ -38,10 +38,10 @@ import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
@@ -71,7 +71,7 @@ class RadioControllerRestoreTest {
private val dataHandler: MeshDataHandler = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
- private val notificationManager: NotificationManager = mock(MockMode.autofill)
+ private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
@@ -99,7 +99,7 @@ class RadioControllerRestoreTest {
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
- notificationManager = notificationManager,
+ serviceNotifications = serviceNotifications,
messageProcessor = lazy { messageProcessor },
radioConfigRepository = radioConfigRepository,
scope = scope,
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt
index c33d51273b..1209469abe 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt
@@ -17,19 +17,42 @@
package org.meshtastic.core.testing
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.FirmwareUpdateNotice
+import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshNotificationManager
+import org.meshtastic.core.repository.Notification
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
-/** A test double for [MeshNotificationManager] that provides a no-op implementation. */
-@Suppress("TooManyFunctions", "EmptyFunctionBlock")
+/** Records every notification posted or cancelled. The `accepts*` flags stand in for a platform that declines. */
+@Suppress("TooManyFunctions")
class FakeMeshNotificationManager : MeshNotificationManager {
- override fun clearNotifications() {}
+ data class ClientPost(val notification: ClientNotification, val title: String, val severity: Notification.Type)
- override fun initChannels() {}
+ var acceptsFirmwareUpdate = true
+ var acceptsReconnectBlocked = true
- override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {}
+ val meshBeacons = mutableListOf()
+ val newNodes = mutableListOf()
+ val cancelledNewNodes = mutableListOf()
+ val lowBatteryShown = mutableListOf()
+ val lowBatteryUpdated = mutableListOf()
+ val lowBatteryCancelled = mutableListOf()
+ val clientPosts = mutableListOf()
+ val clearedClientNotifications = mutableListOf()
+ val firmwareUpdateNotices = mutableListOf()
+ val reconnectBlocked = mutableListOf>()
+ var clearCount = 0
+ private set
+
+ override fun clearNotifications() {
+ clearCount++
+ }
+
+ override fun initChannels() = Unit
+
+ override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) = Unit
override suspend fun updateMessageNotification(
contactKey: String,
@@ -38,7 +61,7 @@ class FakeMeshNotificationManager : MeshNotificationManager {
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
- ) {}
+ ) = Unit
override suspend fun updateWaypointNotification(
contactKey: String,
@@ -46,7 +69,7 @@ class FakeMeshNotificationManager : MeshNotificationManager {
message: String,
waypointId: Int,
isSilent: Boolean,
- ) {}
+ ) = Unit
override suspend fun updateReactionNotification(
contactKey: String,
@@ -55,19 +78,55 @@ class FakeMeshNotificationManager : MeshNotificationManager {
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
- ) {}
+ ) = Unit
- override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
+ override suspend fun showAlertNotification(contactKey: String, name: String, alert: String) = Unit
- override fun showNewNodeSeenNotification(node: Node) {}
+ override suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer) {
+ meshBeacons += offer
+ }
- override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
+ override suspend fun showNewNodeSeenNotification(node: Node, title: String) {
+ newNodes += node
+ }
- override fun showClientNotification(clientNotification: ClientNotification) {}
+ override fun cancelNewNodeNotification(nodeNum: Int) {
+ cancelledNewNodes += nodeNum
+ }
- override suspend fun cancelMessageNotification(contactKey: String) {}
+ override suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean) {
+ lowBatteryShown += node
+ }
- override fun cancelLowBatteryNotification(node: Node) {}
+ override suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean) {
+ lowBatteryUpdated += node
+ }
- override fun clearClientNotification(notification: ClientNotification) {}
+ override fun cancelLowBatteryNotification(node: Node) {
+ lowBatteryCancelled += node
+ }
+
+ override suspend fun showClientNotification(
+ clientNotification: ClientNotification,
+ title: String,
+ severity: Notification.Type,
+ ) {
+ clientPosts += ClientPost(clientNotification, title, severity)
+ }
+
+ override fun clearClientNotification(clientNotification: ClientNotification) {
+ clearedClientNotifications += clientNotification
+ }
+
+ override suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean {
+ if (acceptsFirmwareUpdate) firmwareUpdateNotices += notice
+ return acceptsFirmwareUpdate
+ }
+
+ override suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean {
+ if (acceptsReconnectBlocked) reconnectBlocked += title to message
+ return acceptsReconnectBlocked
+ }
+
+ override suspend fun cancelMessageNotification(contactKey: String) = Unit
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
index 4ff7890392..6cc64472ee 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
@@ -46,20 +46,14 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NodeRestartTracker
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.firmware_update_available
-import org.meshtastic.core.resources.firmware_update_notification_android
-import org.meshtastic.core.resources.firmware_update_notification_flasher
-import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
@@ -107,7 +101,7 @@ class ConnectionsViewModel(
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val radioPrefs: RadioPrefs,
- private val notificationManager: NotificationManager,
+ private val serviceNotifications: MeshNotificationManager,
) : ViewModel() {
private val scheduledFirmwareUpdateNotificationKeys = mutableSetOf()
@@ -296,42 +290,7 @@ class ConnectionsViewModel(
}
.filterNotNull()
.onEach { notice ->
- val message =
- when (notice.destination) {
- org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate ->
- getStringSuspend(
- Res.string.firmware_update_notification_android,
- notice.currentVersion,
- notice.stableVersion,
- )
-
- org.meshtastic.core.model.FirmwareUpdateDestination.MeshtasticFlasher ->
- getStringSuspend(
- Res.string.firmware_update_notification_flasher,
- notice.currentVersion,
- notice.stableVersion,
- )
- }
- if (
- notificationManager.dispatch(
- Notification(
- id = notice.notificationKey.hashCode(),
- title = getStringSuspend(Res.string.firmware_update_available),
- message = message,
- type = Notification.Type.Info,
- category = Notification.Category.NodeEvent,
- deepLinkUri =
- if (
- notice.destination ==
- org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate
- ) {
- "meshtastic:///firmware/update"
- } else {
- "https://flasher.meshtastic.org"
- },
- ),
- )
- ) {
+ if (serviceNotifications.showFirmwareUpdateNotification(notice)) {
scheduledFirmwareUpdateNotificationKeys += notice.notificationKey
uiPrefs.recordFirmwareUpdateNotificationKey(notice.notificationKey)
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index 7798c7ec0d..7fdee9a033 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -59,15 +59,14 @@ import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.LockdownPassphraseStore
import org.meshtastic.core.repository.MeshLogRepository
+import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NodeRestartTracker
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
-import org.meshtastic.core.repository.notificationId
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.compromised_keys
@@ -102,7 +101,7 @@ class UIViewModel(
private val eventFirmwareRepository: EventFirmwareRepository,
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
private val uiPrefs: UiPrefs,
- private val notificationManager: NotificationManager,
+ private val serviceNotifications: MeshNotificationManager,
packetRepository: PacketRepository,
val alertManager: AlertManager,
val snackbarManager: SnackbarManager,
@@ -192,7 +191,7 @@ class UIViewModel(
fun clearClientNotification(notification: ClientNotification) {
serviceRepository.clearClientNotification()
- notificationManager.cancel(notification.notificationId())
+ serviceNotifications.clearClientNotification(notification)
}
val lockdownState = serviceRepository.lockdownState
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
index 2f521d3e9c..3f4b0614d7 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
@@ -31,7 +31,6 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
-import org.meshtastic.core.common.util.safeCatchingAll
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
@@ -39,17 +38,11 @@ import org.meshtastic.core.model.FirmwareUpdateDestination
import org.meshtastic.core.repository.ConnectionIdentity
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRestartTracker
-import org.meshtastic.core.repository.Notification
-import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.firmware_update_available
-import org.meshtastic.core.resources.firmware_update_notification_android
-import org.meshtastic.core.resources.firmware_update_notification_flasher
-import org.meshtastic.core.resources.getString
import org.meshtastic.core.testing.FakeDeviceHardwareRepository
import org.meshtastic.core.testing.FakeFirmwareReleaseRepository
+import org.meshtastic.core.testing.FakeMeshNotificationManager
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioPrefs
import org.meshtastic.core.testing.FakeServiceRepository
@@ -80,26 +73,13 @@ class ConnectionsViewModelTest {
private val deviceHardwareRepository = FakeDeviceHardwareRepository()
private val firmwareReleaseRepository = FakeFirmwareReleaseRepository()
private val radioPrefs = FakeRadioPrefs()
- private val dispatchedNotifications = mutableListOf()
- private var notificationsCanBeScheduled = true
- private val notificationManager =
- object : NotificationManager {
- override suspend fun dispatch(notification: Notification): Boolean {
- if (notificationsCanBeScheduled) dispatchedNotifications += notification
- return notificationsCanBeScheduled
- }
-
- override fun cancel(id: Int) = Unit
-
- override fun cancelAll() = Unit
- }
+ private val serviceNotifications = FakeMeshNotificationManager()
+ private val postedNotices
+ get() = serviceNotifications.firmwareUpdateNotices
@BeforeTest
fun setUp() {
- warmFirmwareNotificationStrings()
Dispatchers.setMain(testDispatcher)
- dispatchedNotifications.clear()
- notificationsCanBeScheduled = true
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig.Builder().build())
every { nodeManager.connectionIdentity } returns connectionIdentity
@@ -121,7 +101,7 @@ class ConnectionsViewModelTest {
deviceHardwareRepository = deviceHardwareRepository,
firmwareReleaseRepository = firmwareReleaseRepository,
radioPrefs = radioPrefs,
- notificationManager = notificationManager,
+ serviceNotifications = serviceNotifications,
)
@AfterTest
@@ -363,11 +343,37 @@ class ConnectionsViewModelTest {
assertEquals("2.7.0", notice.currentVersion)
assertEquals("2.8.0", notice.stableVersion)
assertEquals(FirmwareUpdateDestination.AndroidUpdate, notice.destination)
- assertEquals(1, dispatchedNotifications.size)
+ assertEquals(listOf(notice), postedNotices)
assertEquals(setOf(notice.notificationKey), uiPrefs.firmwareUpdateNotificationKeys.value)
- assertEquals("Firmware update available", dispatchedNotifications.single().title)
- assertEquals(Notification.Type.Info, dispatchedNotifications.single().type)
- assertEquals("meshtastic:///firmware/update", dispatchedNotifications.single().deepLinkUri)
+ }
+
+ @Test
+ fun `firmware notice for flasher-only hardware is still posted`() = runTest {
+ val target = "tbeam"
+ deviceHardwareRepository.setHardware(
+ hwModel = HardwareModel.TBEAM.value,
+ target = target,
+ device = DeviceHardware(architecture = "esp32", platformioTarget = target),
+ )
+ nodeRepository.setMyId("!local")
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target))
+ nodeRepository.setOurNode(
+ org.meshtastic.core.model.Node(
+ num = 1,
+ user = User.Builder().also { wb -> wb.hw_model = HardwareModel.TBEAM }.build(),
+ ),
+ )
+ // ESP32 over serial is not updatable in-app, so the notice's destination is the flasher.
+ radioPrefs.setDevAddr("s:connected")
+ firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target))
+ firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0"))
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+
+ advanceUntilIdle()
+
+ val notice = assertNotNull(viewModel.firmwareUpdateNotice.value)
+ assertEquals(FirmwareUpdateDestination.MeshtasticFlasher, notice.destination)
+ assertEquals(listOf(notice), postedNotices)
}
@Test
@@ -400,7 +406,7 @@ class ConnectionsViewModelTest {
fun `does not persist firmware notification dedupe when scheduling is unavailable`() = runTest {
val hardwareModel = HardwareModel.TBEAM.value
val target = "tbeam"
- notificationsCanBeScheduled = false
+ serviceNotifications.acceptsFirmwareUpdate = false
deviceHardwareRepository.setHardware(
hwModel = hardwareModel,
target = target,
@@ -422,7 +428,7 @@ class ConnectionsViewModelTest {
advanceUntilIdle()
assertNotNull(viewModel.firmwareUpdateNotice.value)
- assertEquals(emptyList(), dispatchedNotifications)
+ assertEquals(emptyList(), postedNotices)
assertEquals(emptySet(), uiPrefs.firmwareUpdateNotificationKeys.value)
}
@@ -477,7 +483,7 @@ class ConnectionsViewModelTest {
advanceUntilIdle()
assertEquals(null, viewModel.firmwareUpdateNotice.value)
- assertEquals(emptyList(), dispatchedNotifications)
+ assertEquals(emptyList(), postedNotices)
}
/**
@@ -492,18 +498,4 @@ class ConnectionsViewModelTest {
fun `RECONNECTING_PROGRESS_TEXT pins the cross-track literal value`() {
assertEquals("Reconnecting\u2026", ServiceRepository.RECONNECTING_PROGRESS_TEXT)
}
-
- /**
- * From CMP 1.12 compose resources load each string once on a library-owned `Dispatchers.Default` scope, which
- * `advanceUntilIdle` cannot drain, so the notification dispatch lands after the assertions. Pre-loading keeps the
- * path inside virtual time on any CMP version; must run before `setMain`. Best-effort: a warm-up that cannot load
- * (skiko's static initializer on the desktop test classpath) must leave the suite as it was, not fail every test.
- */
- private fun warmFirmwareNotificationStrings() {
- safeCatchingAll {
- getString(Res.string.firmware_update_available)
- getString(Res.string.firmware_update_notification_android, "", "")
- getString(Res.string.firmware_update_notification_flasher, "", "")
- }
- }
}
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
index 09e792a754..89a9fbd28f 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
@@ -68,6 +68,7 @@ class DesktopNotificationManager(
Notification.Category.MeshBeacon -> prefs.nodeEventsEnabled.value
Notification.Category.Battery -> prefs.lowBatteryEnabled.value
Notification.Category.Alert -> true
+ Notification.Category.Client -> true
Notification.Category.Service -> true
}
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt
index 265f7e4d96..0fadbb42b8 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt
@@ -139,7 +139,7 @@ class DesktopRuntimeModule {
meshPrefs: MeshPrefs,
uiPrefs: UiPrefs,
databaseManager: DatabaseManager,
- notificationManager: NotificationManager,
+ serviceNotifications: MeshNotificationManager,
messageProcessor: Lazy,
radioConfigRepository: RadioConfigRepository,
scope: ServiceScope,
@@ -156,7 +156,7 @@ class DesktopRuntimeModule {
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
- notificationManager = notificationManager,
+ serviceNotifications = serviceNotifications,
messageProcessor = messageProcessor,
radioConfigRepository = radioConfigRepository,
scope = scope,
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt
index a111c43a8f..f241f1e607 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt
@@ -16,50 +16,37 @@
*/
package org.meshtastic.desktop.notification
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.FirmwareUpdateNotice
+import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.repository.notificationId
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.desktop_notification_title
-import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.firmware_update_available
+import org.meshtastic.core.resources.firmware_update_notification_android
+import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
-import org.meshtastic.core.resources.new_node_seen
+import org.meshtastic.core.resources.mesh_beacon_notification_body
+import org.meshtastic.core.resources.mesh_beacon_notification_title
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
/**
- * Desktop implementation of [MeshNotificationManager].
+ * Desktop implementation of [MeshNotificationManager]: turns each mesh event into a [Notification] record and hands it
+ * to [NotificationManager], which gates it on the user's notification preferences and shows it through the OS.
*
- * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through
- * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications.
- *
- * Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops.
+ * Android-only concepts (notification channels, the foreground-service notification, updating a posted notification in
+ * place) are no-ops here.
*
* Registered manually in `DesktopRuntimeModule` -- do **not** add `@Single` to avoid double-registration with the
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
@Suppress("TooManyFunctions")
-class DesktopMeshNotificationManager(
- private val notificationManager: NotificationManager,
- // Bridges the non-suspend MeshNotificationManager entry points to the suspending NotificationManager.dispatch.
- // Injectable so tests can substitute a TestScope / TestDispatcher.
- @Suppress("InjectDispatcher") private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
-) : MeshNotificationManager {
-
- /**
- * Launches [build] on [scope] and dispatches the resulting [Notification], bridging the non-suspend entry points to
- * the suspending [NotificationManager.dispatch]. [build] runs inside the coroutine so it may call suspend resource
- * getters (e.g. [getString]).
- */
- private fun dispatchAsync(build: suspend () -> Notification) =
- scope.launch { notificationManager.dispatch(build()) }
+class DesktopMeshNotificationManager(private val notificationManager: NotificationManager) : MeshNotificationManager {
override fun clearNotifications() {
notificationManager.cancelAll()
@@ -86,7 +73,6 @@ class DesktopMeshNotificationManager(
title = name,
message = message,
category = Notification.Category.Message,
- contactKey = contactKey,
isSilent = isSilent,
id = contactKey.hashCode(),
),
@@ -100,15 +86,7 @@ class DesktopMeshNotificationManager(
waypointId: Int,
isSilent: Boolean,
) {
- notificationManager.dispatch(
- Notification(
- title = name,
- message = message,
- category = Notification.Category.Message,
- contactKey = contactKey,
- isSilent = isSilent,
- ),
- )
+ notificationManager.dispatch(Notification(title = name, message = message, isSilent = isSilent))
}
override suspend fun updateReactionNotification(
@@ -119,64 +97,100 @@ class DesktopMeshNotificationManager(
channelName: String?,
isSilent: Boolean,
) {
+ notificationManager.dispatch(
+ Notification(title = name, message = emoji, category = Notification.Category.Message, isSilent = isSilent),
+ )
+ }
+
+ override suspend fun showAlertNotification(contactKey: String, name: String, alert: String) {
+ notificationManager.dispatch(
+ Notification(title = name, message = alert, category = Notification.Category.Alert),
+ )
+ }
+
+ override suspend fun showMeshBeaconNotification(offer: MeshBeaconOffer) {
notificationManager.dispatch(
Notification(
- title = name,
- message = emoji,
- category = Notification.Category.Message,
- contactKey = contactKey,
- isSilent = isSilent,
+ title = getStringSuspend(Res.string.mesh_beacon_notification_title),
+ message = offer.message.ifBlank { getStringSuspend(Res.string.mesh_beacon_notification_body) },
+ category = Notification.Category.MeshBeacon,
),
)
}
- override fun showAlertNotification(contactKey: String, name: String, alert: String) {
- dispatchAsync {
- Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey)
- }
- }
-
- override fun showNewNodeSeenNotification(node: Node) {
- dispatchAsync {
+ override suspend fun showNewNodeSeenNotification(node: Node, title: String) {
+ notificationManager.dispatch(
Notification(
- title = getString(Res.string.new_node_seen, node.user.short_name),
+ title = title,
message = node.user.long_name,
category = Notification.Category.NodeEvent,
- )
- }
+ id = node.num,
+ ),
+ )
}
- override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
- dispatchAsync {
+ override fun cancelNewNodeNotification(nodeNum: Int) {
+ notificationManager.cancel(nodeNum)
+ }
+
+ override suspend fun showLowBatteryNotification(node: Node, isRemote: Boolean) {
+ notificationManager.dispatch(
Notification(
- title = getString(Res.string.low_battery_title, node.user.short_name),
- message = getString(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0),
+ title = getStringSuspend(Res.string.low_battery_title, node.user.short_name),
+ message = getStringSuspend(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0),
category = Notification.Category.Battery,
id = node.num,
- )
- }
+ ),
+ )
}
- override fun showClientNotification(clientNotification: ClientNotification) {
- dispatchAsync {
- Notification(
- title = getString(Res.string.desktop_notification_title),
- message = clientNotification.message,
- category = Notification.Category.Alert,
- id = clientNotification.toString().hashCode(),
- )
- }
- }
-
- override suspend fun cancelMessageNotification(contactKey: String) {
- notificationManager.cancel(contactKey.hashCode())
+ override suspend fun updateLowBatteryNotification(node: Node, isRemote: Boolean) {
+ // No-op: an OS notification cannot be refreshed in place, and re-posting would alert again.
}
override fun cancelLowBatteryNotification(node: Node) {
notificationManager.cancel(node.num)
}
- override fun clearClientNotification(notification: ClientNotification) {
- notificationManager.cancel(notification.toString().hashCode())
+ override suspend fun showClientNotification(
+ clientNotification: ClientNotification,
+ title: String,
+ severity: Notification.Type,
+ ) {
+ notificationManager.dispatch(
+ Notification(
+ title = title,
+ message = clientNotification.message,
+ type = severity,
+ category = Notification.Category.Client,
+ id = clientNotification.notificationId(),
+ ),
+ )
+ }
+
+ override fun clearClientNotification(clientNotification: ClientNotification) {
+ notificationManager.cancel(clientNotification.notificationId())
+ }
+
+ override suspend fun showFirmwareUpdateNotification(notice: FirmwareUpdateNotice): Boolean =
+ notificationManager.dispatch(
+ Notification(
+ title = getStringSuspend(Res.string.firmware_update_available),
+ message =
+ getStringSuspend(
+ Res.string.firmware_update_notification_android,
+ notice.currentVersion,
+ notice.stableVersion,
+ ),
+ category = Notification.Category.Service,
+ id = notice.notificationKey.hashCode(),
+ ),
+ )
+
+ // The reconnect-blocked notice is about an Android runtime permission; desktop never raises it.
+ override suspend fun showReconnectBlockedNotification(title: String, message: String): Boolean = false
+
+ override suspend fun cancelMessageNotification(contactKey: String) {
+ notificationManager.cancel(contactKey.hashCode())
}
}
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt
index 9afdd8b3c3..fb93bb0392 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt
@@ -200,10 +200,17 @@ class LinuxNotificationSender(
val category =
when (notification.category) {
Notification.Category.Message -> "im.received"
+
Notification.Category.Battery -> "device.warning"
- Notification.Category.Alert -> "device.error"
+
+ Notification.Category.Alert,
+ Notification.Category.Client,
+ -> "device.error"
+
Notification.Category.NodeEvent -> "network"
+
Notification.Category.MeshBeacon -> "network"
+
Notification.Category.Service -> "device"
}
libnotify.notify_notification_set_category(ptr, category)
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt
index d614e717a3..ea1eeea5d9 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt
@@ -60,10 +60,17 @@ class MacOSNotificationSender private constructor(private val bridge: MacNotific
internal fun categorySubtitle(category: Notification.Category): String = when (category) {
Notification.Category.Message -> "Message"
+
Notification.Category.NodeEvent -> "Node Event"
+
Notification.Category.MeshBeacon -> "Mesh Invitation"
+
Notification.Category.Battery -> "Low Battery"
- Notification.Category.Alert -> "Alert"
+
+ Notification.Category.Alert,
+ Notification.Category.Client,
+ -> "Alert"
+
Notification.Category.Service -> "Service"
}
diff --git a/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManagerTest.kt b/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManagerTest.kt
index 139df5971e..713ea07220 100644
--- a/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManagerTest.kt
+++ b/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManagerTest.kt
@@ -16,44 +16,83 @@
*/
package org.meshtastic.desktop.notification
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.repository.notificationId
+import org.meshtastic.proto.ClientNotification
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
-@OptIn(ExperimentalCoroutinesApi::class)
class DesktopMeshNotificationManagerTest {
- /** Records everything dispatched so the async bridge can be asserted deterministically. */
- private class FakeNotificationManager : NotificationManager {
+ private class FakeNotificationManager(var accepts: Boolean = true) : NotificationManager {
val dispatched = mutableListOf()
+ val cancelled = mutableListOf()
override suspend fun dispatch(notification: Notification): Boolean {
- dispatched.add(notification)
- return true
+ if (accepts) dispatched.add(notification)
+ return accepts
}
- override fun cancel(id: Int) {}
+ override fun cancel(id: Int) {
+ cancelled.add(id)
+ }
override fun cancelAll() {}
}
- @Test
- fun `showAlertNotification dispatches on the injected scope`() = runTest(UnconfinedTestDispatcher()) {
- val notificationManager = FakeNotificationManager()
- // backgroundScope inherits the UnconfinedTestDispatcher, so the launched dispatch runs eagerly and is
- // observable synchronously — no virtual-time advance or manual teardown needed.
- val manager = DesktopMeshNotificationManager(notificationManager, scope = backgroundScope)
+ private val notificationManager = FakeNotificationManager()
+ private val manager = DesktopMeshNotificationManager(notificationManager)
+ @Test
+ fun `critical alerts dispatch in the alert category`() = runTest {
manager.showAlertNotification(contactKey = "contact-1", name = "Alert", alert = "Something happened")
val dispatched = notificationManager.dispatched.single()
assertEquals("Alert", dispatched.title)
assertEquals("Something happened", dispatched.message)
assertEquals(Notification.Category.Alert, dispatched.category)
- assertEquals("contact-1", dispatched.contactKey)
+ }
+
+ @Test
+ fun `client notifications keep their title and severity under a stable id`() = runTest {
+ val clientNotification = ClientNotification.Builder().also { wb -> wb.message = "Duplicate key" }.build()
+
+ manager.showClientNotification(clientNotification, title = "Key conflict", severity = Notification.Type.Warning)
+ manager.clearClientNotification(clientNotification)
+
+ val dispatched = notificationManager.dispatched.single()
+ assertEquals("Key conflict", dispatched.title)
+ assertEquals(Notification.Type.Warning, dispatched.type)
+ assertEquals(Notification.Category.Client, dispatched.category)
+ assertEquals(clientNotification.notificationId(), dispatched.id)
+ assertEquals(listOf(clientNotification.notificationId()), notificationManager.cancelled)
+ }
+
+ @Test
+ fun `a low-battery refresh never re-posts`() = runTest {
+ manager.updateLowBatteryNotification(Node(num = 7), isRemote = false)
+
+ assertTrue(notificationManager.dispatched.isEmpty())
+ }
+
+ @Test
+ fun `new-node notifications cancel by node number`() = runTest {
+ manager.showNewNodeSeenNotification(Node(num = 7), title = "New node seen: N7")
+ manager.cancelNewNodeNotification(7)
+
+ assertEquals(7, notificationManager.dispatched.single().id)
+ assertEquals("New node seen: N7", notificationManager.dispatched.single().title)
+ assertEquals(listOf(7), notificationManager.cancelled)
+ }
+
+ @Test
+ fun `reconnect-blocked is never shown on desktop`() = runTest {
+ assertFalse(manager.showReconnectBlockedNotification("title", "message"))
+ assertTrue(notificationManager.dispatched.isEmpty())
}
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt
index d08747f6eb..ca0ff3f1ef 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt
@@ -18,7 +18,11 @@ package org.meshtastic.feature.firmware
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.repository.FirmwareUpdateProgress
+import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.firmware_update_verifying
+import kotlin.math.roundToInt
/**
* Represents the progress of a long-running firmware update task.
@@ -67,8 +71,14 @@ sealed interface FirmwareUpdateState {
/** Firmware file is being downloaded from the release server. */
data class Downloading(val progressState: ProgressState) : FirmwareUpdateState
- /** Intermediate processing (e.g. extracting, preparing DFU). */
- data class Processing(val progressState: ProgressState) : FirmwareUpdateState
+ /**
+ * Intermediate processing (e.g. extracting, preparing DFU).
+ *
+ * @property beforeConfirmation True while a picked local file is checked before the user has confirmed any update,
+ * so nothing outside the screen reports it as a running update.
+ */
+ data class Processing(val progressState: ProgressState, val beforeConfirmation: Boolean = false) :
+ FirmwareUpdateState
/** Firmware is actively being written to the device. */
data class Updating(val progressState: ProgressState) : FirmwareUpdateState
@@ -118,6 +128,36 @@ sealed interface FirmwareUpdateState {
data class ReviewingBootloader(val versions: BootloaderVersions) : FirmwareUpdateState
}
+/**
+ * The part of this state the foreground-service notification shows, or null when nothing is transferring. Determinate
+ * exactly where the screen draws a determinate bar: downloading and writing, not the processing and verifying waits.
+ */
+internal fun FirmwareUpdateState.toUpdateProgress(): FirmwareUpdateProgress? = when (this) {
+ is FirmwareUpdateState.Downloading -> FirmwareUpdateProgress(progressState.message, progressState.percent())
+
+ is FirmwareUpdateState.Updating -> FirmwareUpdateProgress(progressState.message, progressState.percent())
+
+ is FirmwareUpdateState.Processing ->
+ if (beforeConfirmation) null else FirmwareUpdateProgress(progressState.message, percent = null)
+
+ FirmwareUpdateState.Verifying ->
+ FirmwareUpdateProgress(UiText.Resource(Res.string.firmware_update_verifying), percent = null)
+
+ FirmwareUpdateState.Idle,
+ FirmwareUpdateState.Checking,
+ is FirmwareUpdateState.Ready,
+ FirmwareUpdateState.VerificationFailed,
+ is FirmwareUpdateState.Error,
+ is FirmwareUpdateState.Success,
+ is FirmwareUpdateState.AwaitingFileSave,
+ is FirmwareUpdateState.ReviewingBootloader,
+ -> null
+}
+
+private const val PERCENT = 100
+
+private fun ProgressState.percent(): Int = (progress * PERCENT).roundToInt().coerceIn(0, PERCENT)
+
private val FORMAT_ARG_REGEX = Regex(":?\\s*%1\\\$d%?")
/** Strip positional format arguments (e.g. `%1$d`) from a localized template to get a clean base message. */
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index a790ce71f9..9dd0a4e5cf 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -30,9 +30,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@@ -59,6 +61,7 @@ import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.MaintenanceUf2Repository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NodeRestartTracker
@@ -136,6 +139,7 @@ class FirmwareUpdateViewModel(
private val analytics: PlatformAnalytics,
private val nodeRestartTracker: NodeRestartTracker,
private val bluetoothRepository: BluetoothRepository,
+ private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
) : ViewModel() {
/** The USB maintenance sequence's hold on the radio. Spans several passes, so it cannot use `withOperation`. */
@@ -209,6 +213,14 @@ class FirmwareUpdateViewModel(
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
checkForUpdates()
}
+ // One observer of every state write, so the foreground-service notification can follow a flash the user has
+ // backgrounded without any write site having to remember to publish.
+ viewModelScope.launch {
+ _state
+ .map { it.toUpdateProgress() }
+ .distinctUntilChanged()
+ .collect(firmwareUpdateStatusRepository::publishProgress)
+ }
}
@OptIn(DelicateCoroutinesApi::class)
@@ -221,6 +233,7 @@ class FirmwareUpdateViewModel(
// leaked lock would permanently suppress the radio transport's auto-reconnect for the rest of the app
// session — see startUsbMaintenance/advancePastPass. A no-op if no sequence was in flight.
releaseMaintenanceLease()
+ firmwareUpdateStatusRepository.publishProgress(null)
// viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
// application-wide scope (SupervisorJob + ioDispatcher). ATOMIC start + NonCancellable
// context keeps cleanup running even if something tries to cancel it mid-flight.
@@ -972,7 +985,10 @@ class FirmwareUpdateViewModel(
LocalFirmwareResolution.Invalid(reason = fallbackReason, fileName = fileName)
} else {
val extractingState =
- FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_extracting)))
+ FirmwareUpdateState.Processing(
+ ProgressState(UiText.Resource(Res.string.firmware_update_extracting)),
+ beforeConfirmation = true,
+ )
_state.value = extractingState
try {
val extractedArtifact = extractLocalFirmwareArchive(uri, fileName, state, payloadExtension)
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
index 0695d2c5b1..6328064a07 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
@@ -41,6 +41,7 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MaintenanceUf2Manifest
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.MaintenanceUf2Repository
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PlatformAnalytics
@@ -132,6 +133,7 @@ class FirmwareUpdateIntegrationTest {
analytics,
NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
FakeBluetoothRepository(),
+ FirmwareUpdateStatusRepository(),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateStateTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateStateTest.kt
index e278403b15..9fd9537b14 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateStateTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateStateTest.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.firmware
+import org.meshtastic.core.repository.FirmwareUpdateProgress
import org.meshtastic.core.resources.UiText
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -58,4 +59,29 @@ class FirmwareUpdateStateTest {
fun `stripFormatArgs handles empty string`() {
assertEquals("", "".stripFormatArgs())
}
+
+ @Test
+ fun `writing and downloading report a percent the notification can show`() {
+ val message = UiText.DynamicString("Writing")
+
+ assertEquals(
+ FirmwareUpdateProgress(message, percent = 42),
+ FirmwareUpdateState.Updating(ProgressState(message, 0.42f)).toUpdateProgress(),
+ )
+ assertEquals(100, FirmwareUpdateState.Downloading(ProgressState(message, 1.3f)).toUpdateProgress()?.percent)
+ }
+
+ @Test
+ fun `waits report no percent and states the user acts on report nothing`() {
+ val message = UiText.DynamicString("Waiting for reboot")
+
+ assertEquals(null, FirmwareUpdateState.Processing(ProgressState(message, 0.9f)).toUpdateProgress()?.percent)
+ assertEquals(null, FirmwareUpdateState.Verifying.toUpdateProgress()?.percent)
+ assertEquals(
+ null,
+ FirmwareUpdateState.Processing(ProgressState(message), beforeConfirmation = true).toUpdateProgress(),
+ )
+ assertEquals(null, FirmwareUpdateState.AwaitingFileSave(uf2Artifact = null, fileName = null).toUpdateProgress())
+ assertEquals(null, FirmwareUpdateState.Idle.toUpdateProgress())
+ }
}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index 0e3a83f4be..05cc0c4632 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -26,6 +26,7 @@ import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -49,6 +50,8 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MaintenanceUf2Manifest
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.FirmwareUpdateProgress
+import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.MaintenanceUf2Repository
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PlatformAnalytics
@@ -68,6 +71,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
+import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
@@ -162,8 +166,11 @@ class FirmwareUpdateViewModelTest {
analytics,
NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
bluetoothRepository,
+ firmwareUpdateStatusRepository,
)
+ private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
+
@Test
fun `initialization checks for updates and transitions to Ready`() = runTest {
advanceUntilIdle()
@@ -268,6 +275,30 @@ class FirmwareUpdateViewModelTest {
)
}
+ @Test
+ fun `a running flash publishes its progress for the service notification`() = runTest {
+ advanceUntilIdle()
+ val transferring = CompletableDeferred()
+ val message = UiText.DynamicString("Writing firmware")
+ everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) }
+ .calls {
+ @Suppress("UNCHECKED_CAST")
+ val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
+ updateState(FirmwareUpdateState.Updating(ProgressState(message = message, progress = 0.42f)))
+ transferring.await()
+ null
+ }
+
+ viewModel.startUpdate()
+ runCurrent()
+
+ assertEquals(FirmwareUpdateProgress(message, percent = 42), firmwareUpdateStatusRepository.progress.value)
+
+ transferring.complete(Unit)
+ advanceUntilIdle()
+ assertNotEquals(42, firmwareUpdateStatusRepository.progress.value?.percent)
+ }
+
@Test
fun `startUpdate with wipe factory-resets only after verification succeeds`() = runTest {
advanceUntilIdle()
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index 28cab78586..aee5e6252e 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -56,6 +56,7 @@ import org.meshtastic.core.model.OtafixAssetEntry
import org.meshtastic.core.model.SoftDeviceVariant
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.MaintenanceUf2Repository
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PlatformAnalytics
@@ -100,6 +101,7 @@ class FirmwareUpdateViewModelFileTest {
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill)
private val radioOperationLock = RadioOperationLock()
+ private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private lateinit var viewModel: FirmwareUpdateViewModel
@@ -184,6 +186,7 @@ class FirmwareUpdateViewModelFileTest {
analytics,
NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
FakeBluetoothRepository(),
+ firmwareUpdateStatusRepository,
)
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
@@ -406,6 +409,7 @@ class FirmwareUpdateViewModelFileTest {
val processing = assertIs(viewModel.state.value)
val message = assertIs(processing.progressState.message)
assertEquals(Res.string.firmware_update_extracting, message.res)
+ assertNull(firmwareUpdateStatusRepository.progress.value, "checking a picked file is not a running update")
allowExtraction.complete(Unit)
advanceUntilIdle()