From cf1ca4d02a14e0fcd55eeec8de3c8fc3d621d876 Mon Sep 17 00:00:00 2001
From: ialokim <ialokim@mailbox.org>
Date: Sun, 29 May 2022 14:16:36 +0200
Subject: [PATCH] use native libNotify to show notifications on Linux systems

---
 .../desktop/TestNativeNotifications.java      |  58 -----
 .../briar/desktop/DesktopModule.kt            |   8 +
 .../notification/NotificationProvider.kt      |  35 +++
 .../linux/LibnotifyNotificationProvider.kt    | 235 ++++++++++++++++++
 .../linux/TestNativeNotifications.kt          |  28 +++
 .../briarproject/briar/desktop/ui/BriarUi.kt  |  27 +-
 .../resources/strings/BriarDesktop.properties |   3 +
 .../briar/desktop/DesktopTestModule.kt        |   8 +
 8 files changed, 340 insertions(+), 62 deletions(-)
 delete mode 100644 briar-desktop/src/main/java/org/briarproject/briar/desktop/TestNativeNotifications.java
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/NotificationProvider.kt
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/LibnotifyNotificationProvider.kt
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/TestNativeNotifications.kt

diff --git a/briar-desktop/src/main/java/org/briarproject/briar/desktop/TestNativeNotifications.java b/briar-desktop/src/main/java/org/briarproject/briar/desktop/TestNativeNotifications.java
deleted file mode 100644
index 4fa6efac44..0000000000
--- a/briar-desktop/src/main/java/org/briarproject/briar/desktop/TestNativeNotifications.java
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Briar Desktop
- * Copyright (C) 2021-2022 The Briar Project
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero 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 Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-package org.briarproject.briar.desktop;
-
-import com.sun.jna.Library;
-import com.sun.jna.Native;
-import com.sun.jna.Pointer;
-
-public class TestNativeNotifications {
-
-	public interface LibNotify extends Library {
-		LibNotify INSTANCE = Native.load("notify", LibNotify.class);
-
-		boolean notify_init(String appName);
-
-		Pointer notify_notification_new(String summary, String body,
-				String icon);
-
-		boolean notify_notification_show(Pointer notification, Pointer error);
-	}
-
-	public static void main(String[] args) {
-		System.out.println("Initializing libnotify");
-		LibNotify.INSTANCE.notify_init("jna sandbox");
-
-		System.out.println("Creating a notification");
-		Pointer notification = LibNotify.INSTANCE.notify_notification_new(
-				"Hey there", "You've got 13 new messages",
-				null);
-
-		System.out.println("Sending the notification");
-		LibNotify.INSTANCE.notify_notification_show(notification, null);
-
-		System.out.println("Waiting a few seconds");
-		try {
-			Thread.sleep(3000);
-		} catch (InterruptedException e) {
-			e.printStackTrace();
-		}
-		System.out.println("Done");
-	}
-}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
index e8702cea23..b406225863 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
@@ -52,6 +52,9 @@ import org.briarproject.bramble.util.OsUtils.isMac
 import org.briarproject.briar.attachment.AttachmentModule
 import org.briarproject.briar.desktop.attachment.media.ImageCompressor
 import org.briarproject.briar.desktop.attachment.media.ImageCompressorImpl
+import org.briarproject.briar.desktop.notification.NotificationProvider
+import org.briarproject.briar.desktop.notification.StubNotificationProvider
+import org.briarproject.briar.desktop.notification.linux.LibnotifyNotificationProvider
 import org.briarproject.briar.desktop.settings.UnencryptedSettings
 import org.briarproject.briar.desktop.settings.UnencryptedSettingsImpl
 import org.briarproject.briar.desktop.threading.BriarExecutors
@@ -173,4 +176,9 @@ internal class DesktopModule(
     internal fun provideImageCompressor(imageCompressor: ImageCompressorImpl): ImageCompressor {
         return imageCompressor
     }
+
+    @Provides
+    @Singleton
+    internal fun provideNotificationProvider(): NotificationProvider =
+        if (isLinux()) LibnotifyNotificationProvider else StubNotificationProvider
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/NotificationProvider.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/NotificationProvider.kt
new file mode 100644
index 0000000000..cd844f9167
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/NotificationProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package org.briarproject.briar.desktop.notification
+
+interface NotificationProvider {
+    val available: Boolean
+    fun init()
+    fun uninit()
+    fun notifyPrivateMessages(num: Int)
+}
+
+object StubNotificationProvider : NotificationProvider {
+    override val available: Boolean
+        get() = false
+
+    override fun init() {}
+    override fun uninit() {}
+    override fun notifyPrivateMessages(num: Int) {}
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/LibnotifyNotificationProvider.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/LibnotifyNotificationProvider.kt
new file mode 100644
index 0000000000..cb5617ac2f
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/LibnotifyNotificationProvider.kt
@@ -0,0 +1,235 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.briarproject.briar.desktop.notification.linux
+
+import com.sun.jna.Library
+import com.sun.jna.Native
+import com.sun.jna.Pointer
+import mu.KotlinLogging
+import org.briarproject.briar.desktop.notification.NotificationProvider
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nP
+import org.briarproject.briar.desktop.utils.KLoggerUtils.e
+import org.briarproject.briar.desktop.utils.KLoggerUtils.i
+
+object LibnotifyNotificationProvider : NotificationProvider {
+
+    private val LOG = KotlinLogging.logger {}
+
+    private var isAvailable: Boolean = false
+
+    private lateinit var libNotify: LibNotify
+
+    override val available: Boolean
+        get() = isAvailable
+
+    override fun init() {
+        try {
+            libNotify = Native.load("libnotify.so.4", LibNotify::class.java) // NON-NLS
+        } catch (err: UnsatisfiedLinkError) {
+            LOG.e { "unable to load libnotify" }
+            return
+        }
+
+        isAvailable = libNotify.notify_init(i18n("main.title"))
+        if (!isAvailable) {
+            LOG.e { "unable to initialize libnotify" }
+            return
+        }
+
+        // print notification server capabilities
+        val list = libNotify.notify_get_server_caps()
+        val capabilities = buildList {
+            for (i in 0 until libNotify.g_list_length(list)) {
+                add(libNotify.g_list_nth_data(list, i).getString(0))
+            }
+        }
+        LOG.i { "Notification server capabilities: " + capabilities.joinToString() }
+    }
+
+    override fun uninit() {
+        if (!isAvailable) return
+
+        libNotify.notify_uninit()
+        isAvailable = false
+    }
+
+    override fun notifyPrivateMessages(num: Int) {
+        if (!isAvailable) return
+
+        /**
+         * summary
+         *
+         * This is a single line overview of the notification.
+         * For instance, "You have mail" or "A friend has come online".
+         * It should generally not be longer than 40 characters, though this is not a requirement,
+         * and server implementations should word wrap if necessary.
+         * The summary must be encoded using UTF-8.
+         */
+        // todo: we could use body instead with markup (where supported)
+        val text = i18nP("notifications.message.private", num)
+        val notification = libNotify.notify_notification_new(text, null, null)
+
+        /**
+         * desktop-entry
+         *
+         * This specifies the name of the desktop filename representing the calling program.
+         * This should be the same as the prefix used for the application's .desktop file.
+         * An example would be "rhythmbox" from "rhythmbox.desktop".
+         * This can be used by the daemon to retrieve the correct icon for the application, for logging purposes, etc.
+         */
+        // todo: desktop file usually not present for jar file, provide app_icon/image instead?
+        libNotify.notify_notification_set_desktop_entry(notification, "org.briarproject.Briar")
+
+        /**
+         * suppress-sound
+         *
+         * Causes the server to suppress playing any sounds, if it has that ability.
+         * This is usually set when the client itself is going to play its own sound.
+         */
+        libNotify.notify_notification_set_suppress_sound(notification, true)
+
+        /**
+         * category
+         *
+         * The type of notification this is: A received instant message notification.
+         */
+        libNotify.notify_notification_set_category(notification, "im.received")
+
+        if (!libNotify.notify_notification_show(notification, null)) {
+            // todo: error handling
+            LOG.e { "error while sending notification via libnotify" }
+        }
+    }
+
+    /**
+     * Functions as defined in the source code at
+     * https://www.freedesktop.org/software/gstreamer-sdk/data/docs/latest/glib/glib-GVariant.html
+     * https://www.freedesktop.org/software/gstreamer-sdk/data/docs/latest/glib/glib-Doubly-Linked-Lists.html
+     * https://gitlab.gnome.org/GNOME/libnotify/-/tree/master/libnotify
+     */
+    @Suppress("FunctionName")
+    private interface LibNotify : Library {
+        fun g_list_length(list: Pointer): Int
+        fun g_list_nth_data(list: Pointer, n: Int): Pointer
+
+        /**
+         *  Creates a new boolean GVariant instance -- either TRUE or FALSE.
+         *
+         *  @param value a gboolean value
+         *
+         *  @return a floating reference to a new boolean GVariant instance. [transfer none]
+         *
+         *  @since 2.24
+         */
+        fun g_variant_new_boolean(value: Boolean): Pointer
+
+        /**
+         *  Creates a string GVariant with the contents of [string].
+         *
+         *  @param string a normal utf8 nul-terminated string
+         *
+         *  @return a floating reference to a new string GVariant instance. [transfer none]
+         *
+         *  @since 2.24
+         */
+        fun g_variant_new_string(string: String): Pointer
+
+        /**
+         * Initialize libnotify. This must be called before any other functions.
+         *
+         * @param app_name The name of the application initializing libnotify.
+         *
+         * @return true if successful, or false on error.
+         */
+        fun notify_init(app_name: String): Boolean
+
+        /**
+         * Uninitialize libnotify.
+         *
+         * This should be called when the program no longer needs libnotify for
+         * the rest of its lifecycle, typically just before exiting.
+         */
+        fun notify_uninit()
+
+        /**
+         * Synchronously queries the server for its capabilities and returns them in a #GList.
+         *
+         * @return [Pointer] to a #GList of server capability strings. Free
+         *   the list elements with g_free() and the list itself with g_list_free().
+         */
+        fun notify_get_server_caps(): Pointer
+
+        /**
+         * Creates a new #NotifyNotification. The summary text is required, but
+         * all other parameters are optional.
+         *
+         * @param summary The required summary text.
+         * @param body The optional body text.
+         * @param icon The optional icon theme icon name or filename.
+         *
+         * @return [Pointer] to the new #NotifyNotification.
+         */
+        fun notify_notification_new(summary: String, body: String?, icon: String?): Pointer
+
+        /**
+         * Tells the notification server to display the notification on the screen.
+         *
+         * @param notification [Pointer] to the notification.
+         * @param error The returned error information.
+         *
+         * @return true if successful. On error, this will return false and set [error].
+         */
+        fun notify_notification_show(notification: Pointer, error: Pointer?): Boolean
+
+        /**
+         * Sets a hint for [key] with value [value]. If [value] is null,
+         * a previously set hint for [key] is unset.
+         *
+         * If [value] is floating, it is consumed.
+         *
+         * @param notification [Pointer] to a #NotifyNotification
+         * @param key the hint key
+         * @param value [Pointer] to hint value as GVariant, or null to unset the hint
+         *
+         * @since 0.6
+         */
+        fun notify_notification_set_hint(notification: Pointer, key: String, value: Pointer?)
+
+        /**
+         * Sets the category of this notification. This can be used by the
+         * notification server to filter or display the data in a certain way.
+         *
+         * @param notification [Pointer] to the notification.
+         * @param category The category.
+         */
+        fun notify_notification_set_category(notification: Pointer, category: String)
+    }
+
+    @Suppress("FunctionName")
+    private fun LibNotify.notify_notification_set_desktop_entry(notification: Pointer, desktopEntry: String) {
+        val string = g_variant_new_string(desktopEntry)
+        notify_notification_set_hint(notification, "desktop-entry", string) // NON-NLS
+    }
+
+    @Suppress("FunctionName")
+    private fun LibNotify.notify_notification_set_suppress_sound(notification: Pointer, suppressSound: Boolean) {
+        val bool = g_variant_new_boolean(suppressSound)
+        notify_notification_set_hint(notification, "suppress-sound", bool) // NON-NLS
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/TestNativeNotifications.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/TestNativeNotifications.kt
new file mode 100644
index 0000000000..3f145ae5ae
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/notification/linux/TestNativeNotifications.kt
@@ -0,0 +1,28 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.briarproject.briar.desktop.notification.linux
+
+fun main() {
+    LibnotifyNotificationProvider.apply {
+        init()
+
+        notifyPrivateMessages(9)
+
+        uninit()
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
index cb7000b270..771eb54199 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
@@ -48,9 +48,11 @@ import org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.RU
 import org.briarproject.bramble.api.lifecycle.event.LifecycleEvent
 import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent
 import org.briarproject.briar.desktop.DesktopFeatureFlags
+import org.briarproject.briar.desktop.conversation.ConversationMessagesReadEvent
 import org.briarproject.briar.desktop.expiration.ExpirationBanner
 import org.briarproject.briar.desktop.login.ErrorScreen
 import org.briarproject.briar.desktop.login.StartupScreen
+import org.briarproject.briar.desktop.notification.NotificationProvider
 import org.briarproject.briar.desktop.settings.UnencryptedSettings
 import org.briarproject.briar.desktop.settings.UnencryptedSettings.Theme.AUTO
 import org.briarproject.briar.desktop.settings.UnencryptedSettings.Theme.DARK
@@ -97,6 +99,7 @@ constructor(
     private val unencryptedSettings: UnencryptedSettings,
     private val featureFlags: FeatureFlags,
     private val desktopFeatureFlags: DesktopFeatureFlags,
+    private val notificationProvider: NotificationProvider,
 ) : BriarUi {
 
     private var screenState by mutableStateOf(
@@ -136,11 +139,25 @@ constructor(
                 .toAwtImage(LocalDensity.current, LocalLayoutDirection.current, Size(32f, 32f))
 
             DisposableEffect(Unit) {
+                // todo: hard-coded messageCount doesn't account for unread messages on application start
+                // also see https://code.briarproject.org/briar/briar-desktop/-/issues/133
+                var messageCount = 0
+
                 val eventListener = EventListener { e ->
-                    if (e is LifecycleEvent && e.lifecycleState == RUNNING)
-                        screenState = MAIN
-                    if (e is ConversationMessageReceivedEvent<*> && !focusState.focused) {
-                        window.iconImage = iconBadge
+                    when (e) {
+                        is LifecycleEvent ->
+                            if (e.lifecycleState == RUNNING) screenState = MAIN
+                        is ConversationMessageReceivedEvent<*> -> {
+                            messageCount++
+                            if (!focusState.focused) {
+                                window.iconImage = iconBadge
+                                notificationProvider.notifyPrivateMessages(messageCount)
+                            }
+                        }
+                        is ConversationMessagesReadEvent -> {
+                            messageCount -= e.count
+                            if (messageCount < 0) messageCount = 0
+                        }
                     }
                 }
                 val focusListener = object : WindowFocusListener {
@@ -154,12 +171,14 @@ constructor(
                     }
                 }
 
+                notificationProvider.init()
                 eventBus.addListener(eventListener)
                 window.addWindowFocusListener(focusListener)
 
                 onDispose {
                     eventBus.removeListener(eventListener)
                     window.removeWindowFocusListener(focusListener)
+                    notificationProvider.uninit()
                 }
             }
 
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index 6f33e168bf..c460e74194 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -250,6 +250,9 @@ expiration.banner.part1.zero=This is a test version of Briar that will expire to
 expiration.banner.part1.nozero={0, plural, one {This is a test version of Briar that will expire tomorrow.} other {This is a test version of Briar that will expire in {0} days.}}
 expiration.banner.part2=Please update to a newer version in time.
 
+# Notification
+notifications.message.private={0, plural, one {New private message.} other {{0} new private messages.}}
+
 # Settings
 settings.title=Settings
 
diff --git a/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
index 7145cb8015..6fb2ca9fbc 100644
--- a/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
+++ b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
@@ -53,6 +53,9 @@ import org.briarproject.briar.api.test.TestAvatarCreator
 import org.briarproject.briar.attachment.AttachmentModule
 import org.briarproject.briar.desktop.attachment.media.ImageCompressor
 import org.briarproject.briar.desktop.attachment.media.ImageCompressorImpl
+import org.briarproject.briar.desktop.notification.NotificationProvider
+import org.briarproject.briar.desktop.notification.StubNotificationProvider
+import org.briarproject.briar.desktop.notification.linux.LibnotifyNotificationProvider
 import org.briarproject.briar.desktop.settings.UnencryptedSettings
 import org.briarproject.briar.desktop.settings.UnencryptedSettingsImpl
 import org.briarproject.briar.desktop.testdata.DeterministicTestDataCreator
@@ -180,6 +183,11 @@ internal class DesktopTestModule(
         return imageCompressor
     }
 
+    @Provides
+    @Singleton
+    internal fun provideNotificationProvider(): NotificationProvider =
+        if (isLinux()) LibnotifyNotificationProvider else StubNotificationProvider
+
     @Provides
     @Singleton
     internal fun provideTestAvatarCreator(testAvatarCreator: TestAvatarCreatorImpl): TestAvatarCreator {
-- 
GitLab