feat: Integrate notification management and preferences across platforms (#4819)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-16 20:17:34 -05:00 committed by GitHub
parent 0b2e89c46f
commit 8c964a15ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1304 additions and 61 deletions

View file

@ -19,10 +19,14 @@ package org.meshtastic.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
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.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.FromRadio
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@ -32,7 +36,7 @@ class FromRadioPacketHandlerImpl(
private val router: Lazy<MeshRouter>,
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val serviceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
) : FromRadioPacketHandler {
@Suppress("CyclomaticComplexMethod")
override fun handleFromRadio(proto: FromRadio) {
@ -62,7 +66,13 @@ class FromRadioPacketHandlerImpl(
channel != null -> router.value.configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
notificationManager.dispatch(
Notification(
title = getString(Res.string.client_notification),
message = clientNotification.message,
category = Notification.Category.Alert,
),
)
packetHandler.removeResponse(0, complete = false)
}
}

View file

@ -37,8 +37,8 @@ import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.ServiceBroadcasts
@ -61,7 +61,7 @@ class MeshActionHandlerImpl(
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val databaseManager: DatabaseManager,
private val serviceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -346,7 +346,7 @@ class MeshActionHandlerImpl(
nodeManager.clear()
messageProcessor.value.clearEarlyPackets()
databaseManager.switchActiveDatabase(deviceAddr)
serviceNotifications.clearNotifications()
notificationManager.cancelAll()
nodeManager.loadCachedNodeDB()
}
}

View file

@ -51,6 +51,8 @@ import org.meshtastic.core.repository.MeshServiceNotifications
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
@ -62,6 +64,8 @@ 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.getString
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.AdminMessage
@ -96,6 +100,7 @@ class MeshDataHandlerImpl(
private val serviceRepository: ServiceRepository,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
private val serviceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
@ -396,6 +401,7 @@ class MeshDataHandlerImpl(
rememberDataPacket(dataPacket, myNodeNum)
}
@Suppress("LongMethod")
private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val t =
@ -425,7 +431,18 @@ class MeshDataHandlerImpl(
) {
scope.launch {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
notificationManager.dispatch(
Notification(
title = getString(Res.string.low_battery_title, nextNode.user.short_name),
message =
getString(
Res.string.low_battery_message,
nextNode.user.long_name,
nextNode.deviceMetrics.battery_level ?: 0,
),
category = Notification.Category.Battery,
),
)
}
}
} else {
@ -435,7 +452,7 @@ class MeshDataHandlerImpl(
batteryPercentCooldowns.remove(fromNum)
}
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
notificationManager.cancel(nextNode.num)
}
}
}
@ -642,10 +659,13 @@ class MeshDataHandlerImpl(
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
dataPacket.alert ?: getString(Res.string.critical_alert),
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getString(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
} else if (updateNotification && !isSilent) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
@ -682,12 +702,14 @@ class MeshDataHandlerImpl(
PortNum.WAYPOINT_APP.value -> {
val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
serviceNotifications.updateWaypointNotification(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.waypoint!!.id,
isSilent,
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = message,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
),
)
}

View file

@ -37,10 +37,14 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.core.repository.MeshServiceNotifications
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.ServiceBroadcasts
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
@ -56,7 +60,7 @@ import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImpl(
private val nodeRepository: NodeRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -192,7 +196,13 @@ class NodeManagerImpl(
node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
}
if (newNode && !shouldPreserve) {
serviceNotifications.showNewNodeSeenNotification(next)
notificationManager.dispatch(
Notification(
title = getString(Res.string.new_node_seen, next.user.short_name),
message = next.user.long_name,
category = Notification.Category.NodeEvent,
),
)
}
next
}

View file

@ -18,14 +18,16 @@ package org.meshtastic.core.data.manager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
@ -39,19 +41,23 @@ class FromRadioPacketHandlerImplTest {
private val router: MeshRouter = mockk(relaxed = true)
private val mqttManager: MqttManager = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val notificationManager: NotificationManager = mockk(relaxed = true)
private lateinit var handler: FromRadioPacketHandlerImpl
@Before
fun setup() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { getString(any()) } returns "test string"
every { getString(any(), *anyVararg()) } returns "test string"
handler =
FromRadioPacketHandlerImpl(
serviceRepository,
lazy { router },
mqttManager,
packetHandler,
serviceNotifications,
notificationManager,
)
}
@ -126,7 +132,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) }
verify { serviceNotifications.showClientNotification(notification) }
verify { notificationManager.dispatch(any()) }
verify { packetHandler.removeResponse(0, complete = false) }
}
}

View file

@ -38,6 +38,7 @@ import org.meshtastic.core.repository.MeshServiceNotifications
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
@ -58,6 +59,7 @@ class MeshDataHandlerTest {
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val packetRepositoryLazy: Lazy<PacketRepository> = lazy { packetRepository }
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val notificationManager: NotificationManager = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
private val dataMapper: MeshDataMapper = mockk(relaxed = true)
@ -86,6 +88,7 @@ class MeshDataHandlerTest {
serviceRepository,
packetRepositoryLazy,
serviceBroadcasts,
notificationManager,
serviceNotifications,
analytics,
dataMapper,

View file

@ -16,7 +16,9 @@
*/
package org.meshtastic.core.data.manager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
@ -24,9 +26,10 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
@ -35,13 +38,17 @@ class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val notificationManager: NotificationManager = mockk(relaxed = true)
private lateinit var nodeManager: NodeManagerImpl
@Before
fun setUp() {
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, serviceNotifications)
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { getString(any()) } returns "test string"
every { getString(any(), *anyVararg()) } returns "test string"
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
}
@Test