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

View file

@ -0,0 +1,30 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.NotificationPrefs
/** Use case for updating application-level notification preferences. */
@Single
class SetNotificationSettingsUseCase(private val notificationPrefs: NotificationPrefs) {
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
}

View file

@ -0,0 +1,85 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.notification
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.NotificationPrefs
class NotificationPrefsTest {
@get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
private lateinit var dataStore: DataStore<Preferences>
private lateinit var notificationPrefs: NotificationPrefs
private lateinit var dispatchers: CoroutineDispatchers
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@Before
fun setup() {
dataStore =
PreferenceDataStoreFactory.create(
scope = testScope,
produceFile = { tmpFolder.newFile("test.preferences_pb") },
)
dispatchers = mockk { every { default } returns testDispatcher }
notificationPrefs = NotificationPrefsImpl(dataStore, dispatchers)
}
@Test
fun `messagesEnabled defaults to true`() = testScope.runTest { assertTrue(notificationPrefs.messagesEnabled.value) }
@Test
fun `nodeEventsEnabled defaults to true`() =
testScope.runTest { assertTrue(notificationPrefs.nodeEventsEnabled.value) }
@Test
fun `lowBatteryEnabled defaults to true`() =
testScope.runTest { assertTrue(notificationPrefs.lowBatteryEnabled.value) }
@Test
fun `setting messagesEnabled updates preference`() = testScope.runTest {
notificationPrefs.setMessagesEnabled(false)
assertFalse(notificationPrefs.messagesEnabled.value)
}
@Test
fun `setting nodeEventsEnabled updates preference`() = testScope.runTest {
notificationPrefs.setNodeEventsEnabled(false)
assertFalse(notificationPrefs.nodeEventsEnabled.value)
}
@Test
fun `setting lowBatteryEnabled updates preference`() = testScope.runTest {
notificationPrefs.setLowBatteryEnabled(false)
assertFalse(notificationPrefs.lowBatteryEnabled.value)
}
}

View file

@ -0,0 +1,68 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.notification
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.NotificationPrefs
@Single
class NotificationPrefsImpl(
@Named("UiDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : NotificationPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val messagesEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_MESSAGES_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setMessagesEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_MESSAGES_ENABLED] = enabled } }
}
override val nodeEventsEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_NODE_EVENTS_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setNodeEventsEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_NODE_EVENTS_ENABLED] = enabled } }
}
override val lowBatteryEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_LOW_BATTERY_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setLowBatteryEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_LOW_BATTERY_ENABLED] = enabled } }
}
private companion object {
val KEY_MESSAGES_ENABLED = booleanPreferencesKey("notif_messages_enabled")
val KEY_NODE_EVENTS_ENABLED = booleanPreferencesKey("notif_node_events_enabled")
val KEY_LOW_BATTERY_ENABLED = booleanPreferencesKey("notif_low_battery_enabled")
}
}

View file

@ -84,6 +84,21 @@ interface UiPrefs {
fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
}
/** Reactive interface for notification preferences. */
interface NotificationPrefs {
val messagesEnabled: StateFlow<Boolean>
fun setMessagesEnabled(enabled: Boolean)
val nodeEventsEnabled: StateFlow<Boolean>
fun setNodeEventsEnabled(enabled: Boolean)
val lowBatteryEnabled: StateFlow<Boolean>
fun setLowBatteryEnabled(enabled: Boolean)
}
/** Reactive interface for general map preferences. */
interface MapPrefs {
val mapStyle: StateFlow<Int>

View file

@ -0,0 +1,43 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
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,
) {
enum class Type {
None,
Info,
Warning,
Error,
}
enum class Category {
Message,
NodeEvent,
Battery,
Alert,
Service,
}
}

View file

@ -0,0 +1,25 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
interface NotificationManager {
fun dispatch(notification: Notification)
fun cancel(id: Int)
fun cancelAll()
}

View file

@ -0,0 +1,111 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.app.NotificationChannel
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_alerts_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_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 android.app.NotificationManager as SystemNotificationManager
@Single
class AndroidNotificationManager(private val context: Context) : NotificationManager {
private val notificationManager = context.getSystemService<SystemNotificationManager>()!!
init {
initChannels()
}
private fun initChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channels =
listOf(
createChannel(
Notification.Category.Message,
Res.string.meshtastic_messages_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.NodeEvent,
Res.string.meshtastic_new_nodes_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.Battery,
Res.string.meshtastic_low_battery_notifications,
SystemNotificationManager.IMPORTANCE_DEFAULT,
),
createChannel(
Notification.Category.Alert,
Res.string.meshtastic_alerts_notifications,
SystemNotificationManager.IMPORTANCE_HIGH,
),
createChannel(
Notification.Category.Service,
Res.string.meshtastic_service_notifications,
SystemNotificationManager.IMPORTANCE_MIN,
),
)
notificationManager.createNotificationChannels(channels)
}
}
private fun createChannel(
category: Notification.Category,
nameRes: org.jetbrains.compose.resources.StringResource,
importance: Int,
): NotificationChannel = NotificationChannel(category.name, getString(nameRes), importance)
override fun dispatch(notification: Notification) {
val builder =
NotificationCompat.Builder(context, notification.category.name)
.setContentTitle(notification.title)
.setContentText(notification.message)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setAutoCancel(true)
.setSilent(notification.isSilent)
notification.group?.let { builder.setGroup(it) }
if (notification.type == Notification.Type.Error) {
builder.setPriority(NotificationCompat.PRIORITY_HIGH)
}
val id = notification.id ?: notification.hashCode()
notificationManager.notify(id, builder.build())
}
override fun cancel(id: Int) {
notificationManager.cancel(id)
}
override fun cancelAll() {
notificationManager.cancelAll()
}
}

View file

@ -0,0 +1,77 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationPrefs
import android.app.NotificationManager as SystemNotificationManager
class AndroidNotificationManagerTest {
private lateinit var context: Context
private lateinit var notificationManager: SystemNotificationManager
private lateinit var prefs: NotificationPrefs
private lateinit var androidNotificationManager: AndroidNotificationManager
private val messagesEnabled = MutableStateFlow(true)
private val nodeEventsEnabled = MutableStateFlow(true)
private val lowBatteryEnabled = MutableStateFlow(true)
@Before
fun setup() {
context = mockk(relaxed = true)
notificationManager = mockk(relaxed = true)
prefs = mockk {
every { messagesEnabled } returns this@AndroidNotificationManagerTest.messagesEnabled
every { nodeEventsEnabled } returns this@AndroidNotificationManagerTest.nodeEventsEnabled
every { lowBatteryEnabled } returns this@AndroidNotificationManagerTest.lowBatteryEnabled
}
every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns notificationManager
every { context.packageName } returns "org.meshtastic.test"
// Mocking initChannels to avoid getString calls during initialization for now if possible
// but it's called in init block.
androidNotificationManager = AndroidNotificationManager(context, prefs)
}
@Test
fun `dispatch notifies when enabled`() {
val notification = Notification("Title", "Message", category = Notification.Category.Message)
androidNotificationManager.dispatch(notification)
verify { notificationManager.notify(any(), any()) }
}
@Test
fun `dispatch does not notify when disabled`() {
messagesEnabled.value = false
val notification = Notification("Title", "Message", category = Notification.Category.Message)
androidNotificationManager.dispatch(notification)
verify(exactly = 0) { notificationManager.notify(any(), any()) }
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
class NotificationManagerTest {
@Test
fun `dispatch calls implementation`() {
val manager = mockk<NotificationManager>(relaxed = true)
val notification = Notification("Title", "Message")
manager.dispatch(notification)
verify { manager.dispatch(notification) }
}
}

View file

@ -46,8 +46,8 @@ import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
@ -77,7 +77,7 @@ class UIViewModel(
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
private val alertManager: AlertManager,
) : ViewModel() {
@ -107,7 +107,7 @@ class UIViewModel(
fun clearClientNotification(notification: ClientNotification) {
serviceRepository.clearClientNotification()
meshServiceNotifications.clearClientNotification(notification)
notificationManager.cancel(notification.toString().hashCode())
}
/** Emits events for mesh network send/receive activity. */