feat: enhance desktop notification styling and taskbar integration

- Implement native taskbar and dock icon support using the AWT `Taskbar` API in the desktop entry point.
- Refactor notification icon resolution to use URI strings directly, removing the need to copy resources to temporary files.
- Introduce a notification styling mechanism that automatically prefixes notification titles based on their category (e.g., "Message", "Node", "Battery").
- Update `DesktopSystemNotifier` to provide both small and large icons to `knotify` for improved native presentation.
- Ensure the `AppConfig` for notifications is initialized with the resolved application icon path.
- Update project documentation to reflect the move toward native default styling for notifications.
This commit is contained in:
James Rich
2026-03-24 12:07:53 -05:00
parent b313582e47
commit 1e1feac4d6
8 changed files with 373 additions and 71 deletions

View File

@@ -16,6 +16,14 @@
*/
package org.meshtastic.core.repository
/**
* A notification action button displayed on desktop OS notifications.
*
* @param label The user-visible button text.
* @param onClick Callback invoked when the button is clicked.
*/
data class NotificationButton(val label: String, val onClick: () -> Unit)
data class Notification(
val title: String,
val message: String,
@@ -25,6 +33,12 @@ data class Notification(
val isSilent: Boolean = false,
val group: String? = null,
val id: Int? = null,
/** Callback invoked when the user clicks the notification body. Desktop only. */
val onActivated: (() -> Unit)? = null,
/** Callback invoked when the notification is dismissed by the user. Desktop only. */
val onDismissed: (() -> Unit)? = null,
/** Action buttons shown on the notification. Desktop only (max 2 recommended). */
val buttons: List<NotificationButton> = emptyList(),
) {
enum class Type {
None,

View File

@@ -23,6 +23,8 @@ import io.github.kdroidfilter.knotify.builder.NotificationInitializer
import io.github.kdroidfilter.knotify.builder.notification
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.Notification
import java.util.concurrent.ConcurrentHashMap
import io.github.kdroidfilter.knotify.builder.Notification as KNotifyNotification
interface DesktopSystemNotifier {
fun show(notification: Notification)
@@ -33,26 +35,49 @@ interface DesktopSystemNotifier {
}
@Single
class DesktopSystemNotifierImpl(
private val knotifySender: (Notification) -> Boolean = { notification -> sendWithKnotify(notification) },
) : DesktopSystemNotifier {
class DesktopSystemNotifierImpl(private val knotifySender: ((Notification) -> KNotifyNotification?)? = null) :
DesktopSystemNotifier {
/** Tracks active notifications by ID so they can be hidden on cancel. */
private val activeNotifications = ConcurrentHashMap<Int, KNotifyNotification>()
init {
configureKnotifyIfNeeded()
}
override fun show(notification: Notification) {
if (!knotifySender(notification)) {
if (notification.isSilent) {
Logger.d { "Silent notification suppressed: ${notification.title}" }
return
}
val sender = knotifySender
val knotify =
if (sender != null) {
sender(notification)
} else {
sendWithKnotify(notification)
}
if (knotify != null) {
val id = notification.id ?: notification.hashCode()
activeNotifications[id] = knotify
} else {
Logger.i { "Desktop notification fallback: ${notification.title} - ${notification.message}" }
}
}
override fun cancel(id: Int) {
// The current OS notification backends are fire-and-forget.
activeNotifications.remove(id)?.let { knotify ->
runCatching { knotify.hide() }.onFailure { Logger.w(it) { "Failed to hide notification $id" } }
}
}
override fun cancelAll() {
// The current OS notification backends are fire-and-forget.
activeNotifications.forEach { (id, knotify) ->
runCatching { knotify.hide() }.onFailure { Logger.w(it) { "Failed to hide notification $id" } }
}
activeNotifications.clear()
}
private companion object {
@@ -70,64 +95,53 @@ class DesktopSystemNotifierImpl(
if (knotifyConfigured) return
runCatching {
NotificationInitializer.configure(
AppConfig(
appName = APP_NAME,
smallIcon = notificationIconPath,
),
)
knotifyConfigured = true
}
NotificationInitializer.configure(
AppConfig(appName = APP_NAME, smallIcon = notificationIconPath),
)
knotifyConfigured = true
}
.onFailure { Logger.w(it) { "Failed to configure DesktopNotifyKT" } }
}
}
@OptIn(ExperimentalNotificationsApi::class)
private fun sendWithKnotify(notification: Notification): Boolean {
private fun sendWithKnotify(notification: Notification): KNotifyNotification? = runCatching {
val styled = style(notification)
return runCatching {
notification(
title = styled.title,
message = styled.message,
smallIcon = notificationIconPath,
largeIcon = notificationIconPath,
onFailed = { Logger.w { "DesktopNotifyKT failed to display notification" } },
).send()
true
val knotify =
notification(
title = styled.title,
message = styled.message,
smallIcon = notificationIconPath,
largeIcon = notificationIconPath,
onActivated = notification.onActivated,
onFailed = { Logger.w { "DesktopNotifyKT failed to display notification" } },
) {
notification.buttons.forEach { btn -> button(title = btn.label) { btn.onClick() } }
}
.onFailure { Logger.w(it) { "DesktopNotifyKT threw while sending notification" } }
.getOrDefault(false)
knotify.send()
knotify
}
.onFailure { Logger.w(it) { "DesktopNotifyKT threw while sending notification" } }
.getOrNull()
private fun style(notification: Notification): Notification {
val prefix =
when (notification.category) {
Notification.Category.Message -> "Message"
Notification.Category.NodeEvent -> "Node"
Notification.Category.Battery -> "Battery"
Notification.Category.Alert -> "Alert"
Notification.Category.Service -> "Service"
Notification.Category.Message -> "💬"
Notification.Category.NodeEvent -> "📡"
Notification.Category.Battery -> "🔋"
Notification.Category.Alert -> "⚠️"
Notification.Category.Service -> "⚙️"
}
val title =
when {
notification.title.startsWith("$prefix: ") -> notification.title
else -> "$prefix: ${notification.title}"
}
val title = "$prefix ${notification.title}"
return notification.copy(title = title)
}
private fun resolveResourcePath(resourceName: String): String? {
return runCatching {
Thread.currentThread()
.contextClassLoader
?.getResource(resourceName)
?.toURI()
?.toString()
}
private fun resolveResourcePath(resourceName: String): String? =
runCatching { Thread.currentThread().contextClassLoader?.getResource(resourceName)?.toURI()?.toString() }
.onFailure { Logger.w(it) { "Unable to resolve notification icon resource: $resourceName" } }
.getOrNull()
}
}
}

View File

@@ -204,11 +204,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
}
Tray(
icon = appIcon,
tooltip = "Meshtastic Desktop",
primaryAction = { isAppVisible = true },
) {
Tray(icon = appIcon, tooltip = "Meshtastic Desktop", primaryAction = { isAppVisible = true }) {
Item(label = "Show Meshtastic") { isAppVisible = true }
Item(label = "Quit") { exitApplication() }
}
@@ -217,6 +213,12 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
val backStack =
rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
// Bind the notification navigator so OS notification clicks can route into the app.
val notificationNavigator = remember {
koinApp.koin.get<org.meshtastic.desktop.notification.DesktopNotificationNavigator>()
}
LaunchedEffect(backStack) { notificationNavigator.bind(backStack) { isAppVisible = true } }
Window(
onCloseRequest = { isAppVisible = false },
title = "Meshtastic Desktop",
@@ -311,12 +313,12 @@ private fun setTaskbarDockIcon(resourcePath: String) {
if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return
runCatching {
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(resourcePath) ?: return
stream.use {
val image = ImageIO.read(ByteArrayInputStream(it.readAllBytes())) ?: return
taskbar.iconImage = image
}
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(resourcePath) ?: return
stream.use {
val image = ImageIO.read(ByteArrayInputStream(it.readAllBytes())) ?: return
taskbar.iconImage = image
}
}
.onFailure { Logger.w(it) { "Unable to set dock/taskbar icon from $resourcePath" } }
}

View File

@@ -137,8 +137,14 @@ private fun desktopPlatformStubsModule() = module {
locationManager = get(),
)
}
single<org.meshtastic.desktop.notification.DesktopNotificationNavigator> {
org.meshtastic.desktop.notification.DesktopNotificationNavigatorImpl()
}
single<MeshServiceNotifications> {
org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get())
org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(
notificationManager = get(),
navigator = get(),
)
}
single<PlatformAnalytics> { NoopPlatformAnalytics() }
single<ServiceBroadcasts> { NoopServiceBroadcasts() }

View File

@@ -20,18 +20,23 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationButton
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.resources.Res
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.mark_as_read
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
@Single
@Suppress("TooManyFunctions")
class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications {
class DesktopMeshServiceNotifications(
private val notificationManager: NotificationManager,
private val navigator: DesktopNotificationNavigator,
) : MeshServiceNotifications {
override fun clearNotifications() {
notificationManager.cancelAll()
}
@@ -56,14 +61,29 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
channelName: String?,
isSilent: Boolean,
) {
val title =
if (isBroadcast && channelName != null) {
"$name ($channelName)"
} else {
name
}
val markAsReadLabel = getString(Res.string.mark_as_read)
val notificationId = contactKey.hashCode()
notificationManager.dispatch(
Notification(
title = name,
title = title,
message = message,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
id = contactKey.hashCode(),
id = notificationId,
onActivated = {
navigator.bringWindowToFront()
navigator.navigateToConversation(contactKey)
},
buttons = listOf(NotificationButton(markAsReadLabel) { notificationManager.cancel(notificationId) }),
),
)
}
@@ -82,6 +102,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
onActivated = {
navigator.bringWindowToFront()
navigator.navigateToConversation(contactKey)
},
),
)
}
@@ -94,13 +118,24 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
channelName: String?,
isSilent: Boolean,
) {
val title =
if (isBroadcast && channelName != null) {
"$name ($channelName)"
} else {
name
}
notificationManager.dispatch(
Notification(
title = name,
title = title,
message = emoji,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
onActivated = {
navigator.bringWindowToFront()
navigator.navigateToConversation(contactKey)
},
),
)
}
@@ -113,6 +148,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
message = alert,
category = Notification.Category.Alert,
contactKey = contactKey,
onActivated = {
navigator.bringWindowToFront()
navigator.navigateToConversation(contactKey)
},
),
)
}
@@ -123,6 +162,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
title = getString(Res.string.new_node_seen, node.user.short_name),
message = node.user.long_name,
category = Notification.Category.NodeEvent,
id = node.num,
onActivated = {
navigator.bringWindowToFront()
navigator.navigateToNodeDetail(node.num)
},
),
)
}
@@ -134,6 +178,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
message = getString(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0),
category = Notification.Category.Battery,
id = node.num,
onActivated = {
navigator.bringWindowToFront()
navigator.navigateToNodeDetail(node.num)
},
),
)
}
@@ -145,6 +193,7 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
message = clientNotification.message,
category = Notification.Category.Alert,
id = clientNotification.toString().hashCode(),
onActivated = { navigator.bringWindowToFront() },
),
)
}

View File

@@ -0,0 +1,108 @@
/*
* 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.desktop.notification
import androidx.navigation3.runtime.NavKey
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
/**
* Handles navigation triggered by desktop notification clicks (e.g. "open conversation", "show node detail").
* Dispatches all mutations onto the Swing EDT so that the Compose backstack stays thread-safe.
*/
interface DesktopNotificationNavigator {
/** Navigate to the conversation identified by [contactKey]. */
fun navigateToConversation(contactKey: String)
/** Navigate to the node list. */
fun navigateToNodes()
/** Navigate to a specific node detail screen. */
fun navigateToNodeDetail(nodeNum: Int)
/** Show and focus the main application window. */
fun bringWindowToFront()
/** Bind the navigator to the active navigation backstack and window-visibility toggle. */
fun bind(backStack: MutableList<NavKey>, showWindow: () -> Unit)
}
class DesktopNotificationNavigatorImpl : DesktopNotificationNavigator {
@Volatile private var backStack: MutableList<NavKey>? = null
@Volatile private var showWindow: (() -> Unit)? = null
override fun bind(backStack: MutableList<NavKey>, showWindow: () -> Unit) {
this.backStack = backStack
this.showWindow = showWindow
}
override fun navigateToConversation(contactKey: String) {
navigateOnMain {
navigateTopLevel(TopLevelDestination.Conversations.route)
it.add(ContactsRoutes.Messages(contactKey))
}
}
override fun navigateToNodes() {
navigateOnMain { navigateTopLevel(TopLevelDestination.Nodes.route) }
}
override fun navigateToNodeDetail(nodeNum: Int) {
navigateOnMain {
navigateTopLevel(TopLevelDestination.Nodes.route)
it.add(NodesRoutes.NodeDetail(nodeNum))
}
}
override fun bringWindowToFront() {
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.Swing) { showWindow?.invoke() }
}
/**
* Dispatches a backstack mutation onto the Swing EDT (which is [Dispatchers.Main] for Compose Desktop). knotify
* callbacks arrive on arbitrary threads, so we must marshal.
*/
@Suppress("OPT_IN_USAGE")
private fun navigateOnMain(action: (MutableList<NavKey>) -> Unit) {
val stack = backStack
if (stack == null) {
Logger.w { "DesktopNotificationNavigator: backstack not bound yet, ignoring navigation" }
return
}
GlobalScope.launch(Dispatchers.Swing) {
showWindow?.invoke()
runCatching { action(stack) }
.onFailure { Logger.w(it) { "DesktopNotificationNavigator: navigation failed" } }
}
}
private fun navigateTopLevel(route: NavKey) {
val stack = backStack ?: return
stack.add(route)
while (stack.size > 1) {
stack.removeAt(0)
}
}
}

View File

@@ -18,9 +18,12 @@ package org.meshtastic.desktop
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationButton
import org.meshtastic.core.repository.NotificationPrefs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class DesktopNotificationManagerTest {
@@ -69,6 +72,50 @@ class DesktopNotificationManagerTest {
assertEquals(1, notifier.cancelAllCalls)
}
@Test
fun `dispatch preserves onActivated callback`() {
val notifier = RecordingDesktopSystemNotifier()
val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true)
val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier)
var activated = false
val notification =
Notification(
title = "T",
message = "M",
category = Notification.Category.Message,
onActivated = { activated = true },
)
manager.dispatch(notification)
assertEquals(1, notifier.shown.size)
assertNotNull(notifier.shown.first().onActivated)
notifier.shown.first().onActivated?.invoke()
assertTrue(activated)
}
@Test
fun `dispatch preserves buttons`() {
val notifier = RecordingDesktopSystemNotifier()
val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true)
val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier)
var clicked = false
val notification =
Notification(
title = "T",
message = "M",
category = Notification.Category.Alert,
buttons = listOf(NotificationButton("Do it") { clicked = true }),
)
manager.dispatch(notification)
assertEquals(1, notifier.shown.size)
assertEquals(1, notifier.shown.first().buttons.size)
notifier.shown.first().buttons.first().onClick()
assertTrue(clicked)
}
private class RecordingDesktopSystemNotifier : DesktopSystemNotifier {
val shown = mutableListOf<Notification>()
val canceledIds = mutableListOf<Int>()
@@ -87,11 +134,8 @@ class DesktopNotificationManagerTest {
}
}
private class TestNotificationPrefs(
messages: Boolean,
nodeEvents: Boolean,
lowBattery: Boolean,
) : NotificationPrefs {
private class TestNotificationPrefs(messages: Boolean, nodeEvents: Boolean, lowBattery: Boolean) :
NotificationPrefs {
override val messagesEnabled = MutableStateFlow(messages)
override val nodeEventsEnabled = MutableStateFlow(nodeEvents)
override val lowBatteryEnabled = MutableStateFlow(lowBattery)
@@ -109,4 +153,3 @@ class DesktopNotificationManagerTest {
}
}
}

View File

@@ -17,8 +17,10 @@
package org.meshtastic.desktop
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationButton
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopSystemNotifierTest {
@@ -29,7 +31,7 @@ class DesktopSystemNotifierTest {
DesktopSystemNotifierImpl(
knotifySender = { notification ->
sent += notification
true
null // return null to simulate the KNotifyNotification reference
},
)
@@ -40,11 +42,75 @@ class DesktopSystemNotifierTest {
}
@Test
fun `show does not throw when knotify sender reports failure`() {
val notifier =
DesktopSystemNotifierImpl(knotifySender = { false })
fun `show does not throw when knotify sender returns null`() {
val notifier = DesktopSystemNotifierImpl(knotifySender = { null })
notifier.show(Notification(title = "T", message = "M"))
}
}
@Test
fun `silent notifications are suppressed`() {
val sent = mutableListOf<Notification>()
val notifier =
DesktopSystemNotifierImpl(
knotifySender = { n ->
sent += n
null
},
)
notifier.show(Notification(title = "T", message = "M", isSilent = true))
assertTrue(sent.isEmpty(), "Silent notifications should not be forwarded to knotify")
}
@Test
fun `cancel and cancelAll do not throw on empty tracker`() {
val notifier = DesktopSystemNotifierImpl(knotifySender = { null })
notifier.cancel(42)
notifier.cancelAll()
}
@Test
fun `notification with buttons passes through to sender`() {
val sent = mutableListOf<Notification>()
val notifier =
DesktopSystemNotifierImpl(
knotifySender = { n ->
sent += n
null
},
)
var clicked = false
val notification =
Notification(title = "T", message = "M", buttons = listOf(NotificationButton("Act") { clicked = true }))
notifier.show(notification)
assertEquals(1, sent.size)
assertEquals(1, sent.first().buttons.size)
sent.first().buttons.first().onClick()
assertTrue(clicked)
}
@Test
fun `notification with onActivated passes through to sender`() {
val sent = mutableListOf<Notification>()
val notifier =
DesktopSystemNotifierImpl(
knotifySender = { n ->
sent += n
null
},
)
var activated = false
val notification = Notification(title = "T", message = "M", onActivated = { activated = true })
notifier.show(notification)
assertEquals(1, sent.size)
sent.first().onActivated?.invoke()
assertTrue(activated)
}
}