915 lines
39 KiB
Diff
915 lines
39 KiB
Diff
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 <sitter@kde.org>
|
|
+
|
|
+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("""
|
|
+<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
|
|
+ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
|
|
+<node>
|
|
+ <interface name="org.kde.kickertest">
|
|
+ <method name="DeleteAndRebuildDatabase1"/>
|
|
+ <method name="DeleteAndRebuildDatabase2"/>
|
|
+ </interface>
|
|
+</node>
|
|
+""")
|
|
+ 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 <memory>
|
|
|
|
#include <KDescendantsProxyModel>
|
|
+#include <KLocalizedString>
|
|
+#include <KNotification>
|
|
|
|
#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);
|