mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" } }
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user