mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 18:21:58 -04:00
feat: integrate native desktop notifications and system tray
- Replace Compose-based notifications with `knotify` (DesktopNotifyKT) to provide native OS notification support. - Introduce `DesktopSystemNotifier` and `DesktopSystemNotifierImpl` to handle notification delivery and automatic app icon resource resolution. - Migrate the system tray implementation to `ComposeNativeTray` for improved native menu interactions across Linux, macOS, and Windows. - Refactor `DesktopNotificationManager` to delegate notification lifecycle management to the new system notifier, decoupling it from the Compose UI. - Add unit tests for `DesktopSystemNotifier` and `DesktopNotificationManager` covering notification dispatching and preference filtering. - Update `Main.kt` and project documentation to reflect the new tray and notification architecture.
This commit is contained in:
@@ -36,6 +36,10 @@ The module depends on the JVM variants of KMP modules:
|
||||
|
||||
**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules.
|
||||
|
||||
**Tray:** Uses `ComposeNativeTray` (`io.github.kdroidfilter:composenativetray`) for native tray rendering and menu interactions across Linux, macOS, and Windows.
|
||||
|
||||
**Notifications:** `DesktopNotificationManager` uses DesktopNotifyKT (`io.github.kdroidfilter:knotify`) for desktop notifications. If delivery fails at runtime, the notifier logs the failure as a non-blocking fallback.
|
||||
|
||||
**Localization:** Desktop exposes a language picker, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -155,6 +155,8 @@ dependencies {
|
||||
|
||||
// Compose Desktop
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kdroid.composenativetray)
|
||||
implementation(libs.kdroid.knotify)
|
||||
implementation(libs.compose.multiplatform.material3)
|
||||
implementation(libs.compose.multiplatform.materialIconsExtended)
|
||||
implementation(libs.compose.multiplatform.runtime)
|
||||
|
||||
@@ -16,20 +16,16 @@
|
||||
*/
|
||||
package org.meshtastic.desktop
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
import androidx.compose.ui.window.Notification as ComposeNotification
|
||||
|
||||
@Single
|
||||
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
|
||||
private val _notifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
|
||||
val notifications: SharedFlow<ComposeNotification> = _notifications.asSharedFlow()
|
||||
|
||||
class DesktopNotificationManager(
|
||||
private val prefs: NotificationPrefs,
|
||||
private val systemNotifier: DesktopSystemNotifier,
|
||||
) : NotificationManager {
|
||||
override fun dispatch(notification: Notification) {
|
||||
val enabled =
|
||||
when (notification.category) {
|
||||
@@ -42,22 +38,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
|
||||
|
||||
if (!enabled) return
|
||||
|
||||
val composeType =
|
||||
when (notification.type) {
|
||||
Notification.Type.None -> ComposeNotification.Type.None
|
||||
Notification.Type.Info -> ComposeNotification.Type.Info
|
||||
Notification.Type.Warning -> ComposeNotification.Type.Warning
|
||||
Notification.Type.Error -> ComposeNotification.Type.Error
|
||||
}
|
||||
|
||||
_notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
|
||||
systemNotifier.show(notification)
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) {
|
||||
// Desktop Tray notifications cannot be cancelled once sent via TrayState
|
||||
systemNotifier.cancel(id)
|
||||
}
|
||||
|
||||
override fun cancelAll() {
|
||||
// Desktop Tray notifications cannot be cleared once sent via TrayState
|
||||
systemNotifier.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.github.kdroidfilter.knotify.builder.AppConfig
|
||||
import io.github.kdroidfilter.knotify.builder.ExperimentalNotificationsApi
|
||||
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.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
interface DesktopSystemNotifier {
|
||||
fun show(notification: Notification)
|
||||
|
||||
fun cancel(id: Int)
|
||||
|
||||
fun cancelAll()
|
||||
}
|
||||
|
||||
@Single
|
||||
class DesktopSystemNotifierImpl(
|
||||
private val knotifySender: (Notification) -> Boolean = { notification -> sendWithKnotify(notification) },
|
||||
) : DesktopSystemNotifier {
|
||||
|
||||
init {
|
||||
configureKnotifyIfNeeded()
|
||||
}
|
||||
|
||||
override fun show(notification: Notification) {
|
||||
if (!knotifySender(notification)) {
|
||||
Logger.i { "Desktop notification fallback: ${notification.title} - ${notification.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) {
|
||||
// The current OS notification backends are fire-and-forget.
|
||||
}
|
||||
|
||||
override fun cancelAll() {
|
||||
// The current OS notification backends are fire-and-forget.
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APP_NAME = "Meshtastic"
|
||||
private const val APP_ICON_RESOURCE = "icon.png"
|
||||
|
||||
@Volatile private var knotifyConfigured = false
|
||||
@Volatile private var notificationIconPath: String? = null
|
||||
|
||||
private fun configureKnotifyIfNeeded() {
|
||||
if (knotifyConfigured) return
|
||||
|
||||
synchronized(this) {
|
||||
if (knotifyConfigured) return
|
||||
|
||||
notificationIconPath = resolveResourceToTempPath(APP_ICON_RESOURCE)
|
||||
runCatching {
|
||||
NotificationInitializer.configure(
|
||||
AppConfig(
|
||||
appName = APP_NAME,
|
||||
),
|
||||
)
|
||||
knotifyConfigured = true
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to configure DesktopNotifyKT" } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveResourceToTempPath(resourcePath: String): String? =
|
||||
runCatching {
|
||||
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(resourcePath)
|
||||
stream?.use { input ->
|
||||
val suffix = "." + resourcePath.substringAfterLast('.', "png")
|
||||
val tempFile = Files.createTempFile("meshtastic-knotify-icon-", suffix)
|
||||
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING)
|
||||
tempFile.toFile().deleteOnExit()
|
||||
tempFile.toAbsolutePath().toString()
|
||||
}
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to resolve DesktopNotifyKT icon resource: $resourcePath" } }
|
||||
.getOrNull()
|
||||
|
||||
@OptIn(ExperimentalNotificationsApi::class)
|
||||
private fun sendWithKnotify(notification: Notification): Boolean =
|
||||
runCatching {
|
||||
notification(
|
||||
title = notification.title,
|
||||
message = notification.message,
|
||||
largeIcon = notificationIconPath,
|
||||
smallIcon = notificationIconPath,
|
||||
onFailed = { Logger.w { "DesktopNotifyKT failed to display notification" } },
|
||||
).send()
|
||||
true
|
||||
}
|
||||
.onFailure { Logger.w(it) { "DesktopNotifyKT threw while sending notification" } }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,12 +41,9 @@ import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Notification
|
||||
import androidx.compose.ui.window.Tray
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberTrayState
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
@@ -58,6 +55,7 @@ import coil3.memory.MemoryCache
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.svg.SvgDecoder
|
||||
import com.kdroid.composetray.tray.api.Tray
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okio.Path.Companion.toPath
|
||||
import org.jetbrains.skia.Image
|
||||
@@ -169,18 +167,12 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
||||
|
||||
var isAppVisible by remember { mutableStateOf(true) }
|
||||
var isWindowReady by remember { mutableStateOf(false) }
|
||||
val trayState = rememberTrayState()
|
||||
val appIcon = classpathPainterResource("icon.png")
|
||||
|
||||
val notificationManager = remember { koinApp.koin.get<DesktopNotificationManager>() }
|
||||
val alertManager = remember { koinApp.koin.get<org.meshtastic.core.ui.util.AlertManager>() }
|
||||
val desktopPrefs = remember { koinApp.koin.get<DesktopPreferencesDataSource>() }
|
||||
val windowState = rememberWindowState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val initialWidth = desktopPrefs.windowWidth.first()
|
||||
val initialHeight = desktopPrefs.windowHeight.first()
|
||||
@@ -208,25 +200,13 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
||||
}
|
||||
|
||||
Tray(
|
||||
state = trayState,
|
||||
icon = appIcon,
|
||||
menu = {
|
||||
Item("Show Meshtastic", onClick = { isAppVisible = true })
|
||||
Item(
|
||||
"Test Notification",
|
||||
onClick = {
|
||||
trayState.sendNotification(
|
||||
Notification(
|
||||
"Meshtastic",
|
||||
"This is a test notification from the System Tray",
|
||||
Notification.Type.Info,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
},
|
||||
)
|
||||
tooltip = "Meshtastic Desktop",
|
||||
primaryAction = { isAppVisible = true },
|
||||
) {
|
||||
Item(label = "Show Meshtastic") { isAppVisible = true }
|
||||
Item(label = "Quit") { exitApplication() }
|
||||
}
|
||||
|
||||
if (isWindowReady && isAppVisible) {
|
||||
val backStack =
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopNotificationManagerTest {
|
||||
|
||||
@Test
|
||||
fun `dispatch sends enabled message notifications`() {
|
||||
val notifier = RecordingDesktopSystemNotifier()
|
||||
val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true)
|
||||
val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier)
|
||||
|
||||
val notification = Notification(title = "T", message = "M", category = Notification.Category.Message)
|
||||
manager.dispatch(notification)
|
||||
|
||||
assertEquals(listOf(notification), notifier.shown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispatch skips disabled categories`() {
|
||||
val notifier = RecordingDesktopSystemNotifier()
|
||||
val prefs = TestNotificationPrefs(messages = false, nodeEvents = true, lowBattery = true)
|
||||
val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier)
|
||||
|
||||
manager.dispatch(Notification(title = "T", message = "M", category = Notification.Category.Message))
|
||||
|
||||
assertEquals(emptyList(), notifier.shown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel delegates to notifier`() {
|
||||
val notifier = RecordingDesktopSystemNotifier()
|
||||
val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true)
|
||||
val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier)
|
||||
|
||||
manager.cancel(42)
|
||||
|
||||
assertEquals(listOf(42), notifier.canceledIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelAll delegates to notifier`() {
|
||||
val notifier = RecordingDesktopSystemNotifier()
|
||||
val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true)
|
||||
val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier)
|
||||
|
||||
manager.cancelAll()
|
||||
|
||||
assertEquals(1, notifier.cancelAllCalls)
|
||||
}
|
||||
|
||||
private class RecordingDesktopSystemNotifier : DesktopSystemNotifier {
|
||||
val shown = mutableListOf<Notification>()
|
||||
val canceledIds = mutableListOf<Int>()
|
||||
var cancelAllCalls: Int = 0
|
||||
|
||||
override fun show(notification: Notification) {
|
||||
shown += notification
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) {
|
||||
canceledIds += id
|
||||
}
|
||||
|
||||
override fun cancelAll() {
|
||||
cancelAllCalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
override fun setMessagesEnabled(enabled: Boolean) {
|
||||
messagesEnabled.value = enabled
|
||||
}
|
||||
|
||||
override fun setNodeEventsEnabled(enabled: Boolean) {
|
||||
nodeEventsEnabled.value = enabled
|
||||
}
|
||||
|
||||
override fun setLowBatteryEnabled(enabled: Boolean) {
|
||||
lowBatteryEnabled.value = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopSystemNotifierTest {
|
||||
|
||||
@Test
|
||||
fun `show forwards notification to knotify sender`() {
|
||||
val sent = mutableListOf<Notification>()
|
||||
val notifier =
|
||||
DesktopSystemNotifierImpl(
|
||||
knotifySender = { notification ->
|
||||
sent += notification
|
||||
true
|
||||
},
|
||||
)
|
||||
|
||||
val notification = Notification(title = "T", message = "M")
|
||||
notifier.show(notification)
|
||||
|
||||
assertEquals(listOf(notification), sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show does not throw when knotify sender reports failure`() {
|
||||
val notifier =
|
||||
DesktopSystemNotifierImpl(knotifySender = { false })
|
||||
|
||||
notifier.show(Notification(title = "T", message = "M"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ nordic-dfu = "2.11.0"
|
||||
kmqtt = "1.0.0"
|
||||
jmdns = "3.6.3"
|
||||
qrcode-kotlin = "4.5.0"
|
||||
compose-native-tray = "1.1.0"
|
||||
knotify = "0.4.3"
|
||||
|
||||
[libraries]
|
||||
# AndroidX
|
||||
@@ -202,6 +204,8 @@ aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref
|
||||
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
|
||||
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
|
||||
coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
kdroid-composenativetray = { module = "io.github.kdroidfilter:composenativetray", version.ref = "compose-native-tray" }
|
||||
kdroid-knotify = { module = "io.github.kdroidfilter:knotify", version.ref = "knotify" }
|
||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
|
||||
dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" }
|
||||
|
||||
Reference in New Issue
Block a user