diff --git a/appiumtests/applets/CMakeLists.txt b/appiumtests/applets/CMakeLists.txt index 19229541aa..6715dd1f8f 100644 --- a/appiumtests/applets/CMakeLists.txt +++ b/appiumtests/applets/CMakeLists.txt @@ -59,7 +59,7 @@ add_test( NAME notificationstest COMMAND selenium-webdriver-at-spi-run ${CMAKE_CURRENT_SOURCE_DIR}/notificationstest.py --failfast ) -set_tests_properties(notificationstest PROPERTIES TIMEOUT 120) +set_tests_properties(notificationstest PROPERTIES TIMEOUT 120 ENVIRONMENT "KACTIVITYMANAGERD_PATH=${KDE_INSTALL_FULL_LIBEXECDIR}/kactivitymanagerd;USE_CUSTOM_BUS=1") add_test( NAME digitalclocktest diff --git a/appiumtests/applets/kicker/favoritetest.py b/appiumtests/applets/kicker/favoritetest.py new file mode 100755 index 0000000000..50613c3db1 --- /dev/null +++ b/appiumtests/applets/kicker/favoritetest.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 + +# SPDX-License-Identifier: BSD-3-Clause +# SPDX-FileCopyrightText: 2022-2023 Harald Sitter + +import logging +import os +import shutil +import subprocess +import sys +import tempfile +import time +import unittest +from typing import Final + +from gi.repository import Gio, GLib + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir, "utils")) +from GLibMainLoopThread import GLibMainLoopThread + +KDE_VERSION: Final = 6 +KACTIVITYMANAGERD_SERVICE_NAME: Final = "org.kde.ActivityManager" +KACTIVITYMANAGERD_PATH: Final = os.getenv("KACTIVITYMANAGERD_PATH", "/usr/libexec/kactivitymanagerd") +QMLTEST_EXEC: Final = os.getenv("QMLTEST_EXEC", "/usr/bin/qmltestrunner6") + + +def name_has_owner(session_bus: Gio.DBusConnection, name: str) -> bool: + """ + Whether the given name is available on session bus + """ + message: Gio.DBusMessage = Gio.DBusMessage.new_method_call("org.freedesktop.DBus", "/", "org.freedesktop.DBus", "NameHasOwner") + message.set_body(GLib.Variant("(s)", [name])) + reply, _ = session_bus.send_message_with_reply_sync(message, Gio.DBusSendMessageFlags.NONE, 1000) + return reply and reply.get_signature() == 'b' and reply.get_body().get_child_value(0).get_boolean() + + +def build_ksycoca() -> None: + subprocess.check_call([f"kbuildsycoca{KDE_VERSION}"], stdout=sys.stderr, stderr=sys.stderr, env=os.environ) + + +def start_kactivitymanagerd() -> subprocess.Popen: + session_bus: Gio.DBusConnection = Gio.bus_get_sync(Gio.BusType.SESSION) + assert not name_has_owner(session_bus, KACTIVITYMANAGERD_SERVICE_NAME) + + os.makedirs(os.path.join(GLib.get_user_config_dir(), "menus")) + shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "applications.menu"), os.path.join(GLib.get_user_config_dir(), "menus")) + + kactivitymanagerd = subprocess.Popen([KACTIVITYMANAGERD_PATH], stdout=sys.stderr, stderr=sys.stderr, env=os.environ) + kactivitymanagerd_started: bool = False + for _ in range(10): + if name_has_owner(session_bus, KACTIVITYMANAGERD_SERVICE_NAME): + kactivitymanagerd_started = True + break + logging.info("waiting for kactivitymanagerd to appear on the DBus session") + time.sleep(1) + assert kactivitymanagerd_started + + build_ksycoca() + + return kactivitymanagerd + + +class TestDBusInterface: + """ + D-Bus interface for org.kde.kickertest + """ + + BUS_NAME: Final = "org.kde.kickertest" + OBJECT_PATH: Final = "/test" + INTERFACE_NAME: Final = "org.kde.kickertest" + + connection: Gio.DBusConnection + + def __init__(self) -> None: + self.reg_id: int = 0 + self.owner_id: int = Gio.bus_own_name(Gio.BusType.SESSION, self.BUS_NAME, Gio.BusNameOwnerFlags.NONE, self.on_bus_acquired, None, None) + assert self.owner_id > 0 + + def on_bus_acquired(self, connection: Gio.DBusConnection, name: str, *args) -> None: + """ + The interface is ready, now register objects. + """ + self.connection = connection + introspection_data = Gio.DBusNodeInfo.new_for_xml(""" + + + + + + + +""") + self.reg_id = connection.register_object(self.OBJECT_PATH, introspection_data.interfaces[0], self.handle_method_call, None, None) + assert self.reg_id > 0 + logging.info("interface registered") + + def handle_method_call(self, connection: Gio.DBusConnection, sender: str, object_path: str, interface_name: str, method_name: str, parameters: GLib.Variant, invocation: Gio.DBusMethodInvocation) -> None: + logging.info("method call %s", method_name) + + if method_name == "DeleteAndRebuildDatabase1": + os.remove(KickerTest.desktop_entry_1) + build_ksycoca() + invocation.return_value(None) + elif method_name == "DeleteAndRebuildDatabase2": + os.remove(KickerTest.desktop_entry_2) + build_ksycoca() + invocation.return_value(None) + + +class KickerTest(unittest.TestCase): + kactivitymanagerd: subprocess.Popen + loop_thread: GLibMainLoopThread + dbus_interface: TestDBusInterface + + temp_dir: tempfile.TemporaryDirectory + desktop_entry_1: str + desktop_entry_2: str + + @classmethod + def setUpClass(cls) -> None: + # Prepare desktop files + # 1 + os.makedirs(os.path.join(GLib.get_user_data_dir(), "applications")) + shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "kickertest.desktop"), os.path.join(GLib.get_user_data_dir(), "applications")) + cls.desktop_entry_1 = os.path.join(GLib.get_user_data_dir(), "applications", "kickertest.desktop") + # 2 + cls.temp_dir = tempfile.TemporaryDirectory() + os.makedirs(os.path.join(cls.temp_dir.name, "applications")) + shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "kickertest.desktop"), os.path.join(cls.temp_dir.name, "applications")) + cls.desktop_entry_2 = os.path.join(cls.temp_dir.name, "applications", "kickertest.desktop") + + os.environ["LC_ALL"] = "en_US.UTF-8" + os.environ["QT_LOGGING_RULES"] = "org.kde.plasma.kicker.debug=true;kf.coreaddons.kdirwatch.debug=true" + os.environ["XDG_DATA_DIRS"] = os.environ["XDG_DATA_DIRS"] + ":" + cls.temp_dir.name + + cls.kactivitymanagerd = start_kactivitymanagerd() + + cls.loop_thread = GLibMainLoopThread() + cls.loop_thread.start() + cls.dbus_interface = TestDBusInterface() + + @classmethod + def tearDownClass(cls) -> None: + cls.loop_thread.quit() + cls.kactivitymanagerd.kill() + cls.kactivitymanagerd.wait(10) + + def test_qml(self) -> None: + """ + 1. Add an entry to Favorites + 2. Remove the entry from Favorites + 3. Hide invalid entries automatically and don't crash when there are multiple entries with the same desktop name + """ + with subprocess.Popen([QMLTEST_EXEC, "-input", os.path.join(os.path.dirname(os.path.abspath(__file__)), 'favoritetest.qml')], stdout=sys.stderr, stderr=sys.stderr) as process: + self.assertEqual(process.wait(60), 0) + + +if __name__ == '__main__': + assert "USE_CUSTOM_BUS" in os.environ + logging.getLogger().setLevel(logging.INFO) + unittest.main() diff --git a/appiumtests/applets/notificationstest.py b/appiumtests/applets/notificationstest.py index 8917121a94..cd34a32905 100755 --- a/appiumtests/applets/notificationstest.py +++ b/appiumtests/applets/notificationstest.py @@ -5,6 +5,7 @@ import base64 import os +import shutil import subprocess import tempfile import time @@ -15,6 +16,7 @@ import gi from appium import webdriver from appium.options.common.base import AppiumOptions from appium.webdriver.common.appiumby import AppiumBy +from selenium.common.exceptions import (NoSuchElementException, WebDriverException) from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait @@ -22,6 +24,8 @@ gi.require_version('Gdk', '4.0') gi.require_version('GdkPixbuf', '2.0') from gi.repository import Gdk, GdkPixbuf, Gio, GLib +from kicker.favoritetest import start_kactivitymanagerd + WIDGET_ID: Final = "org.kde.plasma.notifications" KDE_VERSION: Final = 6 @@ -48,17 +52,25 @@ class NotificationsTest(unittest.TestCase): """ driver: webdriver.Remote + kactivitymanagerd: subprocess.Popen @classmethod def setUpClass(cls) -> None: """ Opens the widget and initialize the webdriver """ + # Make history work + cls.kactivitymanagerd = start_kactivitymanagerd() + + os.makedirs(os.path.join(GLib.get_user_data_dir(), "knotifications6")) + shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir, "libnotificationmanager", "libnotificationmanager.notifyrc"), os.path.join(GLib.get_user_data_dir(), "knotifications6")) + options = AppiumOptions() options.set_capability("app", f"plasmawindowed -p org.kde.plasma.nano {WIDGET_ID}") options.set_capability("timeouts", {'implicit': 10000}) options.set_capability("environ", { "LC_ALL": "en_US.UTF-8", + "QT_LOGGING_RULES": "kf.notification*.debug=true;org.kde.plasma.notificationmanager.debug=true", }) cls.driver = webdriver.Remote(command_executor='http://127.0.0.1:4723', options=options) @@ -75,6 +87,8 @@ class NotificationsTest(unittest.TestCase): Make sure to terminate the driver again, lest it dangles. """ subprocess.check_call([f"kquitapp{KDE_VERSION}", "plasmawindowed"]) + cls.kactivitymanagerd.kill() + cls.kactivitymanagerd.wait(10) for _ in range(10): try: subprocess.check_call(["pidof", "plasmawindowed"]) @@ -83,6 +97,15 @@ class NotificationsTest(unittest.TestCase): time.sleep(1) cls.driver.quit() + def close_notifications(self) -> None: + wait = WebDriverWait(self.driver, 5) + for button in self.driver.find_elements(AppiumBy.XPATH, "//button[@name='Close']"): + try: + button.click() + wait.until_not(lambda _: button.is_displayed()) + except WebDriverException: + pass + def test_0_open(self) -> None: """ Tests the widget can be opened @@ -106,6 +129,10 @@ class NotificationsTest(unittest.TestCase): wait = WebDriverWait(self.driver, 5) wait.until(EC.presence_of_element_located((AppiumBy.NAME, summary))) +<<<<<<< HEAD +======= + self.close_notifications() +>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off) def take_screenshot(self) -> str: with tempfile.TemporaryDirectory() as temp_dir: @@ -123,6 +150,7 @@ class NotificationsTest(unittest.TestCase): partial_pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 16, 16) colors = (0xff0000ff, 0x00ff00ff, 0x0000ffff) for color in colors: + logging.info(f"Testing color: {color}") pixbuf.fill(color) send_notification({ "app_name": "Appium Test", @@ -145,7 +173,29 @@ class NotificationsTest(unittest.TestCase): wait.until(EC.presence_of_element_located((AppiumBy.NAME, summary + str(color)))) partial_pixbuf.fill(color) partial_image = base64.b64encode(Gdk.Texture.new_for_pixbuf(partial_pixbuf).save_to_png_bytes().get_data()).decode() +<<<<<<< HEAD self.driver.find_image_occurrence(self.take_screenshot(), partial_image) +======= + try: + self.driver.find_image_occurrence(self.take_screenshot(), partial_image) + except WebDriverException: # Popup animation + self.driver.find_image_occurrence(self.take_screenshot(), partial_image) + self.close_notifications() + + def test_2_notification_with_explicit_timeout(self) -> None: + """ + Sends notifications with expire_timeout + """ + summary = "expire_timeout" + send_notification({ + "app_name": "Appium Test", + "summary": summary, + "body": "Will it disappear automatically?", + "timeout": 2000, + }) + element = self.driver.find_element(AppiumBy.NAME, summary) + WebDriverWait(self.driver, 5).until_not(lambda _: element.is_displayed()) +>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off) def test_3_accessible_description_html_to_plaintext(self) -> None: """ @@ -157,6 +207,202 @@ class NotificationsTest(unittest.TestCase): }) wait = WebDriverWait(self.driver, 5) wait.until(EC.presence_of_element_located(("description", "biublinkwww.example.org from Appium Test"))) +<<<<<<< HEAD +======= + self.close_notifications() + + def test_4_actions(self) -> None: + """ + When the "actions" key is set, a notification can provide actions. + """ + loop = GLib.MainLoop() + activation_token = False + params_1: list[Any] = [] + action_invoked = False + params_2: list[Any] = [] + notification_closed = False + params_3: list[Any] = [] + + def notification_signal_handler(d_bus_proxy: Gio.DBusProxy, sender_name: str, signal_name: str, parameters: GLib.Variant) -> None: + nonlocal params_2, params_3, params_1, activation_token, action_invoked, notification_closed + logging.info(f"received signal {signal_name}") + match signal_name: + case "ActivationToken": + params_1 = parameters.unpack() + activation_token = True + case "ActionInvoked": + params_2 = parameters.unpack() + action_invoked = True + case "NotificationClosed": + params_3 = parameters.unpack() + notification_closed = True + loop.quit() + + connection_id = self.notification_proxy.connect("g-signal", notification_signal_handler) + self.addCleanup(lambda: self.notification_proxy.disconnect(connection_id)) + + notification_id = send_notification({ + "app_name": "Appium Test", + "body": "A notification with actions", + "actions": ["action1", "FooAction", "action2", "BarAction"], + }) + self.driver.find_element(AppiumBy.NAME, "BarAction") + element = self.driver.find_element(AppiumBy.NAME, "FooAction") + element.click() + loop.run() + self.assertTrue(activation_token) + self.assertEqual(params_1[0], notification_id) + self.assertTrue(action_invoked) + self.assertEqual(params_2[0], notification_id) + self.assertEqual(params_2[1], "action1") + self.assertTrue(notification_closed) + self.assertEqual(params_3[0], notification_id) + self.assertEqual(params_3[1], 3) # reason: Revoked + self.assertFalse(element.is_displayed()) + + def test_5_inline_reply(self) -> None: + """ + When the action list has "inline-reply", the notification popup will contain a text field and a reply button. + """ + loop = GLib.MainLoop() + notification_replied = False + params: list[Any] = [] # id, text + + def notification_signal_handler(d_bus_proxy: Gio.DBusProxy, sender_name: str, signal_name: str, parameters: GLib.Variant) -> None: + nonlocal params, notification_replied + logging.info(f"received signal {signal_name}") + if signal_name == "NotificationReplied": + params = parameters.unpack() + notification_replied = True + loop.quit() + + connection_id = self.notification_proxy.connect("g-signal", notification_signal_handler) + self.addCleanup(lambda: self.notification_proxy.disconnect(connection_id)) + + # When there is only one action and it is a reply action, show text field right away + notification_id = send_notification({ + "app_name": "Appium Test", + "body": "A notification with actions 1", + "actions": ["inline-reply", ""], # Use the default label + }) + reply_text = "this is a reply" + self.driver.find_element(AppiumBy.NAME, "begin reply").click() + self.driver.find_element(AppiumBy.NAME, "Type a reply…").send_keys(reply_text) + element = self.driver.find_element(AppiumBy.NAME, "Send") + element.click() + loop.run() + self.assertTrue(notification_replied) + self.assertEqual(params[0], notification_id) + self.assertEqual(params[1], reply_text) + self.assertFalse(element.is_displayed()) + + notification_replied = False + notification_id = send_notification({ + "app_name": "Appium Test", + "body": "A notification with actions 2", + "actions": ["inline-reply", ""], + "hints": { + "x-kde-reply-submit-button-text": GLib.Variant("s", "Reeply"), # Use a custom label + "x-kde-reply-placeholder-text": GLib.Variant("s", "A placeholder"), # Use a custom placeholder + }, + }) + reply_text = "this is another reply" + self.driver.find_element(AppiumBy.NAME, "begin reply").click() + self.driver.find_element(AppiumBy.NAME, "A placeholder").send_keys(reply_text) + element = self.driver.find_element(AppiumBy.NAME, "Reeply") + element.click() + loop.run() + self.assertTrue(notification_replied) + self.assertEqual(params[0], notification_id) + self.assertEqual(params[1], reply_text) + self.assertFalse(element.is_displayed()) + + notification_replied = False + notification_id = send_notification({ + "app_name": "Appium Test", + "body": "A notification with actions 3", + "actions": ["inline-reply", "Replyy", "foo", "Foo", "bar", "Bar"], # Click to show the text field + }) + self.driver.find_element(AppiumBy.NAME, "Foo") + self.driver.find_element(AppiumBy.NAME, "Bar") + element = self.driver.find_element(AppiumBy.NAME, "Replyy") + element.click() + reply_text = "Click Replyy to reply" + self.driver.find_element(AppiumBy.NAME, "Type a reply…").send_keys(reply_text) + self.assertFalse(element.is_displayed()) + element = self.driver.find_element(AppiumBy.NAME, "Send") + element.click() + loop.run() + self.assertTrue(notification_replied) + self.assertEqual(params[0], notification_id) + self.assertEqual(params[1], reply_text) + + def test_6_thumbnail(self) -> None: + """ + When a notification has "x-kde-urls" hint, a thumbnail will be shown for the first url in the list + """ + with tempfile.TemporaryDirectory() as temp_dir: + pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 256, 256) + colors = (0xff0000ff, 0x00ff00ff, 0x0000ffff) + for color in colors: + pixbuf.fill(color) + pixbuf.savev(os.path.join(temp_dir, f"{str(color)}.png"), "png") + + url_list = [f"file://{os.path.join(temp_dir, path)}" for path in os.listdir(temp_dir)] + url_list.sort() + send_notification({ + "app_name": "Appium Test", + "body": "Thumbnail", + "hints": { + "x-kde-urls": GLib.Variant("as", url_list), + }, + "timeout": 10 * 1000, + }) + + self.driver.find_element(AppiumBy.NAME, "More Options…") + + partial_pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 100, 100) + partial_pixbuf.fill(colors[1]) # Green is the first item + partial_image = base64.b64encode(Gdk.Texture.new_for_pixbuf(partial_pixbuf).save_to_png_bytes().get_data()).decode() + + def match_image(driver) -> bool: + try: + self.driver.find_image_occurrence(self.take_screenshot(), partial_image) + return True + except WebDriverException: + return False + + WebDriverWait(self.driver, 10).until(match_image) + self.close_notifications() + + def test_7_do_not_disturb(self) -> None: + """ + Suppress inhibited notifications after "Do not disturb" is turned off, and show a summary for unread inhibited notifications. + """ + self.driver.find_element(AppiumBy.NAME, "Do not disturb").click() + dnd_button = self.driver.find_element(AppiumBy.XPATH, "//*[@name='Do not disturb' and contains(@states, 'checked')]") + + summary = "Do not disturb me" + for i in range(2): + send_notification({ + "app_name": "Appium Test", + "summary": summary + str(i), + "hints": { + "desktop-entry": GLib.Variant("s", "org.kde.plasmashell"), + }, + "timeout": 60 * 1000, + }) + title = self.driver.find_element(AppiumBy.XPATH, f"//heading[starts-with(@name, '{summary}') and contains(@accessibility-id, 'FullRepresentation')]") + self.assertRaises(NoSuchElementException, self.driver.find_element, AppiumBy.XPATH, f"//notification[starts-with(@name, '{summary}')]") + + dnd_button.click() + self.driver.find_element(AppiumBy.XPATH, "//notification[@name='Unread Notifications' and @description='2 notifications were received while Do Not Disturb was active. from Notification Manager']") + self.driver.find_element(AppiumBy.XPATH, "//button[@name='Close' and contains(@accessibility-id, 'NotificationPopup')]").click() + + # Notifications can only be cleared after they are expired, otherwise they will stay in the list + self.driver.find_element(AppiumBy.NAME, "Clear All Notifications").click() + WebDriverWait(self.driver, 5).until_not(lambda _: title.is_displayed()) +>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off) if __name__ == '__main__': diff --git a/applets/notifications/package/contents/ui/FullRepresentation.qml b/applets/notifications/package/contents/ui/FullRepresentation.qml index f949dad46a..33838599f6 100644 --- a/applets/notifications/package/contents/ui/FullRepresentation.qml +++ b/applets/notifications/package/contents/ui/FullRepresentation.qml @@ -71,6 +71,14 @@ PlasmaExtras.Representation { checkable: true checked: Globals.inhibited + Accessible.onPressAction: if (Globals.inhibited) { + Globals.revokeInhibitions(); + } else { + let date = new Date(); + date.setFullYear(date.getFullYear() + 1); + notificationSettings.notificationsInhibitedUntil = date; + notificationSettings.save(); + } KeyNavigation.down: list KeyNavigation.tab: list diff --git a/applets/notifications/package/contents/ui/global/Globals.qml b/applets/notifications/package/contents/ui/global/Globals.qml index c46c32921a..2238653eff 100644 --- a/applets/notifications/package/contents/ui/global/Globals.qml +++ b/applets/notifications/package/contents/ui/global/Globals.qml @@ -34,6 +34,10 @@ QtObject { property bool inhibited: false onInhibitedChanged: { + if (!inhibited) { + popupNotificationsModel.showInhibitionSummary(); + } + var pa = pulseAudio.item; if (!pa) { return; @@ -405,6 +409,7 @@ QtObject { limit: plasmoid ? (Math.ceil(globals.screenRect.height / (Kirigami.Units.gridUnit * 4))) : 0 showExpired: false showDismissed: false + showAddedDuringInhibition: false blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices whitelistedDesktopEntries: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedApplications : [] @@ -613,9 +618,13 @@ QtObject { onKillJobClicked: popupNotificationsModel.killJob(popupNotificationsModel.index(index, 0)) // popup width is fixed - onHeightChanged: positionPopups() + onHeightChanged: globals.positionPopups() Component.onCompleted: { + if (globals.inhibited) { + model.wasAddedDuringInhibition = false; // Don't count already shown notifications + } + if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry) { // Register apps that were seen spawning a popup so they can be configured later // Apps with notifyrc can already be configured anyway diff --git a/libnotificationmanager/CMakeLists.txt b/libnotificationmanager/CMakeLists.txt index d04d8a4a24..5c48653ede 100644 --- a/libnotificationmanager/CMakeLists.txt +++ b/libnotificationmanager/CMakeLists.txt @@ -84,6 +84,7 @@ target_link_libraries(notificationmanager KF6::I18n KF6::WindowSystem KF6::ItemModels # KDescendantsProxyModel + KF6::Notifications # Inhibition summary KF6::KIOFileWidgets Plasma::Plasma KF6::Screen @@ -135,3 +136,6 @@ install(EXPORT notificationmanagerLibraryTargets install(FILES plasmanotifyrc DESTINATION ${KDE_INSTALL_CONFDIR}) + +install(FILES libnotificationmanager.notifyrc + DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR}) diff --git a/libnotificationmanager/abstractnotificationsmodel.cpp b/libnotificationmanager/abstractnotificationsmodel.cpp index 8bd55bc26c..8307a1e8f7 100644 --- a/libnotificationmanager/abstractnotificationsmodel.cpp +++ b/libnotificationmanager/abstractnotificationsmodel.cpp @@ -104,6 +104,7 @@ void AbstractNotificationsModel::Private::onNotificationReplaced(uint replacedId newNotification.setExpired(oldNotification.expired()); newNotification.setDismissed(oldNotification.dismissed()); newNotification.setRead(oldNotification.read()); + newNotification.setWasAddedDuringInhibition(Server::self().inhibited()); notifications[row] = newNotification; const QModelIndex idx = q->index(row, 0); @@ -378,6 +379,9 @@ QVariant AbstractNotificationsModel::data(const QModelIndex &index, int role) co case Notifications::TransientRole: return notification.transient(); + case Notifications::WasAddedDuringInhibitionRole: + return notification.wasAddedDuringInhibition(); + case Notifications::HasReplyActionRole: return notification.hasReplyAction(); case Notifications::ReplyActionLabelRole: @@ -416,6 +420,12 @@ bool AbstractNotificationsModel::setData(const QModelIndex &index, const QVarian dirty = true; } break; + case Notifications::WasAddedDuringInhibitionRole: + if (bool v = value.toBool(); v != notification.wasAddedDuringInhibition()) { + notification.setWasAddedDuringInhibition(v); + dirty = true; + } + break; } if (dirty) { @@ -471,7 +481,7 @@ void AbstractNotificationsModel::clear(Notifications::ClearFlags flags) for (int i = 0; i < d->notifications.count(); ++i) { const Notification ¬ification = d->notifications.at(i); - if (flags.testFlag(Notifications::ClearExpired) && notification.expired()) { + if (flags.testFlag(Notifications::ClearExpired) && (notification.expired() || notification.wasAddedDuringInhibition())) { close(notification.id()); } } diff --git a/libnotificationmanager/libnotificationmanager.notifyrc b/libnotificationmanager/libnotificationmanager.notifyrc new file mode 100644 index 0000000000..79304e62bc --- /dev/null +++ b/libnotificationmanager/libnotificationmanager.notifyrc @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: CC0-1.0 +# SPDX-FileCopyrightText: None + +[Global] +Name=Notification Manager +IconName=preferences-desktop-notification-bell + +[Event/inhibitionSummary] +Name=Summary for unread inhibited notifications +Action=Popup +Urgency=Low diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp index 276611310c..320c837617 100644 --- a/libnotificationmanager/notification.cpp +++ b/libnotificationmanager/notification.cpp @@ -826,3 +826,13 @@ void Notification::processHints(const QVariantMap &hints) { d->processHints(hints); } + +bool Notification::wasAddedDuringInhibition() const +{ + return d->wasAddedDuringInhibition; +} + +void Notification::setWasAddedDuringInhibition(bool value) +{ + d->wasAddedDuringInhibition = value; +} diff --git a/libnotificationmanager/notification.h b/libnotificationmanager/notification.h index 9f04a4e200..8613534fd2 100644 --- a/libnotificationmanager/notification.h +++ b/libnotificationmanager/notification.h @@ -131,6 +131,9 @@ public: void processHints(const QVariantMap &hints); + bool wasAddedDuringInhibition() const; + void setWasAddedDuringInhibition(bool value); + private: friend class NotificationsModel; friend class AbstractNotificationsModel; diff --git a/libnotificationmanager/notification_p.h b/libnotificationmanager/notification_p.h index 6cae23c21e..923bde7882 100644 --- a/libnotificationmanager/notification_p.h +++ b/libnotificationmanager/notification_p.h @@ -96,6 +96,8 @@ public: bool resident = false; bool transient = false; + + bool wasAddedDuringInhibition = false; }; } // namespace NotificationManager diff --git a/libnotificationmanager/notificationfilterproxymodel.cpp b/libnotificationmanager/notificationfilterproxymodel.cpp index 98a32b6645..bb573713af 100644 --- a/libnotificationmanager/notificationfilterproxymodel.cpp +++ b/libnotificationmanager/notificationfilterproxymodel.cpp @@ -58,6 +58,20 @@ void NotificationFilterProxyModel::setShowDismissed(bool show) } } +bool NotificationFilterProxyModel::showAddedDuringInhibition() const +{ + return m_showDismissed; +} + +void NotificationFilterProxyModel::setShowAddedDuringInhibition(bool show) +{ + if (m_showAddedDuringInhibition != show) { + m_showAddedDuringInhibition = show; + invalidateFilter(); + Q_EMIT showAddedDuringInhibitionChanged(); + } +} + QStringList NotificationFilterProxyModel::blacklistedDesktopEntries() const { return m_blacklistedDesktopEntries; @@ -177,5 +191,9 @@ bool NotificationFilterProxyModel::filterAcceptsRow(int source_row, const QModel } } + if (!m_showAddedDuringInhibition && sourceIdx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) { + return false; + } + return true; } diff --git a/libnotificationmanager/notificationfilterproxymodel_p.h b/libnotificationmanager/notificationfilterproxymodel_p.h index 4029320e8e..af04a9fac1 100644 --- a/libnotificationmanager/notificationfilterproxymodel_p.h +++ b/libnotificationmanager/notificationfilterproxymodel_p.h @@ -30,6 +30,9 @@ public: bool showDismissed() const; void setShowDismissed(bool show); + bool showAddedDuringInhibition() const; + void setShowAddedDuringInhibition(bool show); + QStringList blacklistedDesktopEntries() const; void setBlackListedDesktopEntries(const QStringList &blacklist); @@ -46,6 +49,7 @@ Q_SIGNALS: void urgenciesChanged(); void showExpiredChanged(); void showDismissedChanged(); + void showAddedDuringInhibitionChanged(); void blacklistedDesktopEntriesChanged(); void blacklistedNotifyRcNamesChanged(); void whitelistedDesktopEntriesChanged(); @@ -58,6 +62,7 @@ private: Notifications::Urgencies m_urgencies = Notifications::LowUrgency | Notifications::NormalUrgency | Notifications::CriticalUrgency; bool m_showDismissed = false; bool m_showExpired = false; + bool m_showAddedDuringInhibition = true; QStringList m_blacklistedDesktopEntries; QStringList m_blacklistedNotifyRcNames; diff --git a/libnotificationmanager/notifications.cpp b/libnotificationmanager/notifications.cpp index 9b3e18018b..3d5662f926 100644 --- a/libnotificationmanager/notifications.cpp +++ b/libnotificationmanager/notifications.cpp @@ -12,6 +12,8 @@ #include #include +#include +#include #include "limitedrowcountproxymodel_p.h" #include "notificationfilterproxymodel_p.h" @@ -30,6 +32,7 @@ #include "debug.h" +using namespace Qt::StringLiterals; using namespace NotificationManager; class Q_DECL_HIDDEN Notifications::Private @@ -166,6 +169,7 @@ void Notifications::Private::initProxyModels() connect(filterModel, &NotificationFilterProxyModel::urgenciesChanged, q, &Notifications::urgenciesChanged); connect(filterModel, &NotificationFilterProxyModel::showExpiredChanged, q, &Notifications::showExpiredChanged); connect(filterModel, &NotificationFilterProxyModel::showDismissedChanged, q, &Notifications::showDismissedChanged); + connect(filterModel, &NotificationFilterProxyModel::showAddedDuringInhibitionChanged, q, &Notifications::showAddedDuringInhibitionChanged); connect(filterModel, &NotificationFilterProxyModel::blacklistedDesktopEntriesChanged, q, &Notifications::blacklistedDesktopEntriesChanged); connect(filterModel, &NotificationFilterProxyModel::blacklistedNotifyRcNamesChanged, q, &Notifications::blacklistedNotifyRcNamesChanged); @@ -245,7 +249,7 @@ void Notifications::Private::updateCount() for (int i = 0; i < filterModel->rowCount(); ++i) { const QModelIndex idx = filterModel->index(i, 0); - if (idx.data(Notifications::ExpiredRole).toBool()) { + if (idx.data(Notifications::ExpiredRole).toBool() || idx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) { ++expired; } else { ++active; @@ -477,6 +481,16 @@ void Notifications::setShowDismissed(bool show) d->filterModel->setShowDismissed(show); } +bool Notifications::showAddedDuringInhibition() const +{ + return d->filterModel->showAddedDuringInhibition(); +} + +void Notifications::setShowAddedDuringInhibition(bool show) +{ + d->filterModel->setShowAddedDuringInhibition(show); +} + QStringList Notifications::blacklistedDesktopEntries() const { return d->filterModel->blacklistedDesktopEntries(); @@ -812,6 +826,28 @@ void Notifications::collapseAllGroups() } } +void Notifications::showInhibitionSummary() +{ + int inhibited = 0; + for (int i = 0, count = d->notificationsAndJobsModel->rowCount(); i < count; ++i) { + const QModelIndex idx = d->notificationsAndJobsModel->index(i, 0); + if (!idx.data(Notifications::ReadRole).toBool() && idx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) { + ++inhibited; + } + } + + if (!inhibited) { + return; + } + + KNotification::event(u"inhibitionSummary"_s, + i18nc("@title", "Unread Notifications"), + i18nc("@info", "%1 notifications were received while Do Not Disturb was active.", QString::number(inhibited)), + u"preferences-desktop-notification-bell"_s, + KNotification::CloseOnTimeout, + u"libnotificationmanager"_s); +} + QVariant Notifications::data(const QModelIndex &index, int role) const { return QSortFilterProxyModel::data(index, role); diff --git a/libnotificationmanager/notifications.h b/libnotificationmanager/notifications.h index edb898988f..ef500b0c7b 100644 --- a/libnotificationmanager/notifications.h +++ b/libnotificationmanager/notifications.h @@ -61,6 +61,15 @@ class NOTIFICATIONMANAGER_EXPORT Notifications : public QSortFilterProxyModel, p */ Q_PROPERTY(bool showDismissed READ showDismissed WRITE setShowDismissed NOTIFY showDismissedChanged) + /** + * Whether to show notifications added during inhibition. + * + * If set to @c false, notifications are suppressed even after leaving "Do not disturb" mode. + * + * Default is @c true. + */ + Q_PROPERTY(bool showAddedDuringInhibition READ showAddedDuringInhibition WRITE setShowAddedDuringInhibition NOTIFY showAddedDuringInhibitionChanged) + /** * A list of desktop entries for which no notifications should be shown. * @@ -285,6 +294,8 @@ public: ///< notification in a certain way, or group notifications of similar types. @since 5.21 ResidentRole, ///< Whether the notification should keep its actions even when they were invoked. @since 5.22 TransientRole, ///< Whether the notification is transient and should not be kept in history. @since 5.22 + + WasAddedDuringInhibitionRole, ///< Whether the notification was added while inhibition was active. @since 6.3 }; Q_ENUM(Roles) @@ -371,6 +382,9 @@ public: bool showDismissed() const; void setShowDismissed(bool show); + bool showAddedDuringInhibition() const; + void setShowAddedDuringInhibition(bool show); + QStringList blacklistedDesktopEntries() const; void setBlacklistedDesktopEntries(const QStringList &blacklist); @@ -529,6 +543,11 @@ public: Q_INVOKABLE void collapseAllGroups(); + /** + * Shows a notification to report the number of unread inhibited notifications. + */ + Q_INVOKABLE void showInhibitionSummary(); + QVariant data(const QModelIndex &index, int role) const override; bool setData(const QModelIndex &index, const QVariant &value, int role) override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; @@ -541,6 +560,7 @@ Q_SIGNALS: void limitChanged(); void showExpiredChanged(); void showDismissedChanged(); + void showAddedDuringInhibitionChanged(); void blacklistedDesktopEntriesChanged(); void blacklistedNotifyRcNamesChanged(); void whitelistedDesktopEntriesChanged(); diff --git a/libnotificationmanager/server_p.cpp b/libnotificationmanager/server_p.cpp index 84fe37afa9..66cd621033 100644 --- a/libnotificationmanager/server_p.cpp +++ b/libnotificationmanager/server_p.cpp @@ -164,6 +164,7 @@ uint ServerPrivate::Notify(const QString &app_name, notification.setActions(actions); notification.setTimeout(timeout); + notification.setWasAddedDuringInhibition(m_inhibited); // might override some of the things we set above (like application name) notification.d->processHints(hints);