Initial support for mako notification daemon.

This commit is contained in:
2026-04-06 21:09:41 -04:00
parent 91edf64a72
commit a8950c42e2
9 changed files with 1265 additions and 85 deletions
+9
View File
@@ -261,6 +261,7 @@ from . import event_manager
from . import keybindings
from . import learn_mode_presenter
from . import logger
from . import mako_notification_monitor
from . import messages
from . import notification_presenter
from . import focus_manager
@@ -719,6 +720,7 @@ def start() -> None:
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Starting Atspi main event loop', True)
# Start D-Bus remote controller service after ATSPI is ready
cthulhuApp.getMakoNotificationMonitor().start()
GObject.idle_add(_start_dbus_service)
Atspi.event_main()
@@ -771,6 +773,7 @@ def shutdown(script: Optional[Any] = None, inputEvent: Optional[Any] = None) ->
cthulhuApp.getSignalManager().emitSignal('stop-application-completed')
sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1)
cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
cthulhuApp.getMakoNotificationMonitor().stop()
# Deactivate the event manager first so that it clears its queue and will not
# accept new events. Then let the script manager unregister script event listeners.
@@ -958,6 +961,8 @@ class Cthulhu(GObject.Object):
self.translationManager: TranslationManager = translation_manager.TranslationManager(self)
self.debugManager: Any = debug
self.APIHelper: APIHelper = APIHelper(self)
self.makoNotificationMonitor: mako_notification_monitor.MakoNotificationMonitor = \
mako_notification_monitor.MakoNotificationMonitor(self)
self.createCompatAPI()
self.pluginSystemManager: PluginSystemManager = plugin_system_manager.PluginSystemManager(self)
# Scan for available plugins at startup
@@ -969,6 +974,9 @@ class Cthulhu(GObject.Object):
def getPluginSystemManager(self) -> PluginSystemManager:
return self.pluginSystemManager
def getMakoNotificationMonitor(self) -> mako_notification_monitor.MakoNotificationMonitor:
return self.makoNotificationMonitor
def getDynamicApiManager(self) -> DynamicApiManager:
return self.dynamicApiManager
@@ -1027,6 +1035,7 @@ class Cthulhu(GObject.Object):
self.getDynamicApiManager().registerAPI('Messages', messages)
self.getDynamicApiManager().registerAPI('Cmdnames', cmdnames)
self.getDynamicApiManager().registerAPI('NotificationPresenter', notification_presenter)
self.getDynamicApiManager().registerAPI('MakoNotificationMonitor', self.makoNotificationMonitor)
self.getDynamicApiManager().registerAPI('CthulhuState', cthulhu_state)
self.getDynamicApiManager().registerAPI('CthulhuPlatform', cthulhu_platform)
self.getDynamicApiManager().registerAPI('Settings', settings)
+16
View File
@@ -376,6 +376,22 @@ NOTIFICATIONS_COLUMN_HEADER = C_("notification presenter", "Notifications")
# for the time, which will be relative (e.g. "10 minutes ago") or absolute.
NOTIFICATIONS_RECEIVED_TIME = C_("notification presenter", "Received")
# Translators: This is the label for a button which dismisses the selected live
# notification from mako's queue.
NOTIFICATIONS_DISMISS_BUTTON = C_("notification presenter", "Dismiss Notification")
# Translators: This is the label for a button which opens the list of actions
# for the selected live notification.
NOTIFICATIONS_ACTIONS_BUTTON = C_("notification presenter", "Notification Actions")
# Translators: This is the title for the dialog listing the actions associated
# with the selected live notification.
NOTIFICATIONS_ACTIONS_TITLE = C_("notification presenter", "Notification Actions")
# Translators: This is the label for a button which invokes the selected action
# from the notification actions dialog.
NOTIFICATIONS_INVOKE_ACTION_BUTTON = C_("notification presenter", "Invoke Action")
# Translators: This string is a label for the group of Cthulhu commands which
# are associated with presenting notifications.
KB_GROUP_NOTIFICATIONS = _("Notification presenter")
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
"""Core support for speaking mako notifications via D-Bus."""
from __future__ import annotations
import html
import re
from typing import Any, Optional
from gi.repository import Gio, GLib
from . import cthulhu_state
from . import debug
from . import messages
from . import notification_presenter
from . import settings
class MakoNotificationMonitor:
"""Tracks mako's live D-Bus queue and presents new notifications."""
BUS_NAME = "org.freedesktop.Notifications"
OBJECT_PATH = "/fr/emersion/Mako"
INTERFACE_NAME = "fr.emersion.Mako"
SOURCE_NAME = "mako"
def __init__(self, app) -> None:
self._app = app
self._presenter = None
self._connection: Optional[Gio.DBusConnection] = None
self._watch_id: int = 0
self._properties_subscription_id: int = 0
self._known_ids: set[int] = set()
self._is_running: bool = False
self._is_available: bool = False
self._generation: int = 0
self._tag_pattern = re.compile(r"<[^>]+>")
self._whitespace_pattern = re.compile(r"\s+")
def start(self) -> None:
"""Starts watching the session bus for mako."""
if self._is_running:
return
try:
self._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
except Exception as error:
msg = f"MAKO MONITOR: Failed to connect to session bus: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
self._watch_id = Gio.bus_watch_name_on_connection(
self._connection,
self.BUS_NAME,
Gio.BusNameWatcherFlags.NONE,
self._on_name_appeared,
self._on_name_vanished,
)
self._is_running = True
debug.printMessage(debug.LEVEL_INFO, "MAKO MONITOR: Started", True)
def stop(self, reset_live_state: bool = True) -> None:
"""Stops watching the session bus for mako."""
if self._properties_subscription_id and self._connection is not None:
self._connection.signal_unsubscribe(self._properties_subscription_id)
self._properties_subscription_id = 0
if self._watch_id:
Gio.bus_unwatch_name(self._watch_id)
self._watch_id = 0
if reset_live_state:
self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
self._known_ids.clear()
self._is_available = False
self._is_running = False
self._connection = None
debug.printMessage(debug.LEVEL_INFO, "MAKO MONITOR: Stopped", True)
def refresh(self, announce_new: bool = False) -> bool:
"""Refreshes mako's live queue and optionally speaks new notifications."""
if self._connection is None or not self._is_available:
return False
live_notifications = self._fetch_live_notifications()
if live_notifications is None:
return False
self._sync_notifications(live_notifications, announce_new)
return True
def dismiss_notification(self, notification_id: int) -> bool:
"""Dismisses a live mako notification by ID."""
if self._connection is None or not self._is_available:
return False
try:
options = {
"id": GLib.Variant("u", notification_id),
"group": GLib.Variant("b", False),
"history": GLib.Variant("b", True),
"all": GLib.Variant("b", False),
}
self._connection.call_sync(
self.BUS_NAME,
self.OBJECT_PATH,
self.INTERFACE_NAME,
"DismissNotifications",
GLib.Variant("(a{sv})", (options,)),
None,
Gio.DBusCallFlags.NONE,
-1,
None,
)
except Exception as error:
msg = f"MAKO MONITOR: Failed to dismiss notification {notification_id}: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
self.refresh(announce_new=False)
return True
def invoke_action(self, notification_id: int, action_key: str) -> bool:
"""Invokes a live mako notification action."""
if self._connection is None or not self._is_available:
return False
try:
self._connection.call_sync(
self.BUS_NAME,
self.OBJECT_PATH,
self.INTERFACE_NAME,
"InvokeAction",
GLib.Variant("(us)", (notification_id, action_key)),
None,
Gio.DBusCallFlags.NONE,
-1,
None,
)
except Exception as error:
msg = (
f"MAKO MONITOR: Failed to invoke action {action_key} "
f"for notification {notification_id}: {error}"
)
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
self.refresh(announce_new=False)
return True
def is_current_entry(self, entry) -> bool:
"""Returns True if entry belongs to the current mako session."""
return (
self._is_available
and entry.source == self.SOURCE_NAME
and entry.source_generation == self._generation
)
def get_generation(self) -> int:
"""Returns the current mako session generation."""
return self._generation
def _get_presenter(self):
presenter = self._presenter or notification_presenter.getPresenter()
if presenter is not self._presenter:
self._presenter = presenter
if getattr(presenter, "_mako_monitor", None) is not self:
presenter.set_mako_monitor(self)
return presenter
def _on_name_appeared(
self,
connection: Gio.DBusConnection,
name: str,
name_owner: str,
user_data: Optional[Any] = None,
) -> None:
msg = f"MAKO MONITOR: {name} appeared as {name_owner}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._connection = connection
self._subscribe_to_properties()
live_notifications = self._fetch_live_notifications()
if live_notifications is None:
self._unsubscribe_from_properties()
self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
self._known_ids.clear()
self._is_available = False
debug.printMessage(
debug.LEVEL_INFO,
"MAKO MONITOR: Notification daemon is not exposing the mako interface",
True,
)
return
self._generation += 1
self._is_available = True
self._known_ids.clear()
self._sync_notifications(live_notifications, announce_new=False)
def _on_name_vanished(
self,
connection: Gio.DBusConnection,
name: str,
user_data: Optional[Any] = None,
) -> None:
msg = f"MAKO MONITOR: {name} vanished"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._unsubscribe_from_properties()
self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
self._known_ids.clear()
self._is_available = False
def _subscribe_to_properties(self) -> None:
if self._connection is None or self._properties_subscription_id:
return
self._properties_subscription_id = self._connection.signal_subscribe(
self.BUS_NAME,
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
self.OBJECT_PATH,
self.INTERFACE_NAME,
Gio.DBusSignalFlags.NONE,
self._on_properties_changed,
)
def _unsubscribe_from_properties(self) -> None:
if self._connection is None or not self._properties_subscription_id:
return
self._connection.signal_unsubscribe(self._properties_subscription_id)
self._properties_subscription_id = 0
def _on_properties_changed(
self,
connection: Gio.DBusConnection,
sender_name: Optional[str],
object_path: str,
interface_name: str,
signal_name: str,
parameters: GLib.Variant,
user_data: Optional[Any] = None,
) -> None:
del connection, sender_name, object_path, interface_name, signal_name, parameters, user_data
self.refresh(announce_new=True)
def _fetch_live_notifications(self) -> Optional[list[dict[str, Any]]]:
if self._connection is None:
return None
try:
reply = self._connection.call_sync(
self.BUS_NAME,
self.OBJECT_PATH,
self.INTERFACE_NAME,
"ListNotifications",
None,
None,
Gio.DBusCallFlags.NONE,
-1,
None,
)
except Exception as error:
msg = f"MAKO MONITOR: Failed to list notifications: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return None
try:
unpacked = reply.unpack()
except Exception as error:
msg = f"MAKO MONITOR: Failed to unpack notification list: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return None
if not unpacked:
return []
return list(unpacked[0])
def _sync_notifications(
self,
live_notifications: list[dict[str, Any]],
announce_new: bool,
) -> None:
live_by_id: dict[int, dict[str, Any]] = {}
ordered_ids: list[int] = []
for notification in live_notifications:
parsed = self._parse_notification(notification)
notification_id = parsed.get("notification_id")
if notification_id is None:
continue
live_by_id[notification_id] = parsed
ordered_ids.append(notification_id)
self._get_presenter().sync_live_notifications(
self.SOURCE_NAME,
live_by_id,
self._generation,
)
if announce_new:
for notification_id in ordered_ids:
if notification_id in self._known_ids:
continue
self._announce_notification(live_by_id[notification_id])
self._known_ids = set(live_by_id)
def _announce_notification(self, notification: dict[str, Any]) -> None:
message = notification.get("message") or ""
if not message:
return
self._get_presenter().save_notification(
message,
source=self.SOURCE_NAME,
source_generation=self._generation,
notification_id=notification.get("notification_id"),
live=True,
actions=notification.get("actions"),
app_name=notification.get("app_name", ""),
summary=notification.get("summary", ""),
body=notification.get("body", ""),
urgency=notification.get("urgency", -1),
desktop_entry=notification.get("desktop_entry", ""),
)
script = cthulhu_state.activeScript or self._app.getScriptManager().get_default_script()
if script is None:
return
voice = script.speechGenerator.voice(string=message)
script.speakMessage(message, voice=voice)
script.displayBrailleMessage(message, flashTime=settings.brailleFlashTime)
def _parse_notification(self, notification: dict[str, Any]) -> dict[str, Any]:
actions = notification.get("actions") or {}
if not isinstance(actions, dict):
actions = {}
app_name = self._normalize_text(notification.get("app-name", ""))
summary = self._normalize_text(notification.get("summary", ""))
body = self._normalize_text(notification.get("body", ""))
desktop_entry = self._normalize_text(notification.get("desktop-entry", ""))
urgency = notification.get("urgency", -1)
notification_id = notification.get("id")
return {
"notification_id": notification_id if isinstance(notification_id, int) else None,
"actions": {
str(key): self._normalize_text(value) or str(key)
for key, value in actions.items()
},
"app_name": app_name,
"summary": summary,
"body": body,
"urgency": urgency if isinstance(urgency, int) else -1,
"desktop_entry": desktop_entry,
"message": self._build_message(app_name, summary, body, desktop_entry),
}
def _build_message(
self,
app_name: str,
summary: str,
body: str,
desktop_entry: str,
) -> str:
parts = [messages.NOTIFICATION]
if summary:
parts.append(summary)
if body and body != summary:
parts.append(body)
if len(parts) == 1:
fallback = app_name or desktop_entry
if fallback:
parts.append(fallback)
return " ".join(part for part in parts if part).strip()
def _normalize_text(self, text: Any) -> str:
if not isinstance(text, str):
return ""
text = html.unescape(text)
text = self._tag_pattern.sub(" ", text)
text = self._whitespace_pattern.sub(" ", text)
return text.strip()
+1
View File
@@ -60,6 +60,7 @@ cthulhu_python_sources = files([
'learn_mode_presenter.py',
'liveregions.py',
'logger.py',
'mako_notification_monitor.py',
'mathsymbols.py',
'messages.py',
'mouse_review.py',
+15
View File
@@ -2011,6 +2011,21 @@ NOTIFICATION_LIST_BOTTOM = C_("notification", "Bottom")
# list of notifications is reached.
NOTIFICATION_LIST_TOP = C_("notification", "Top")
# Translators: This message is presented when a live notification can no longer
# be controlled because it has already been closed or replaced.
NOTIFICATION_UNAVAILABLE = _("Notification is no longer available")
# Translators: This message is presented when the selected live notification
# does not expose any actions.
NOTIFICATION_NO_ACTIONS = _("No notification actions available")
# Translators: This message is presented after dismissing a live notification.
NOTIFICATION_DISMISSED = _("Notification dismissed")
# Translators: This message is presented after invoking an action on a live
# notification.
NOTIFICATION_ACTION_INVOKED = _("Notification action invoked")
# Translators: This message is presented to the user when the notifications list
# is empty.
NOTIFICATION_NO_MESSAGES = _("No notification messages")
+456 -85
View File
@@ -23,24 +23,18 @@
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
"""Module for notification messages"""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2023 Igalia, S.L." \
"Copyright (c) 2010 Informal Informatica LTDA."
__license__ = "LGPL"
"""Module for notification messages."""
from dataclasses import dataclass, field
import time
from typing import Optional, Dict, List, Any, Callable
import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Gdk
from . import cmdnames
from . import debug
@@ -48,13 +42,32 @@ from . import guilabels
from . import input_event
from . import keybindings
from . import messages
from . import cthulhu_state
@dataclass
class NotificationEntry:
"""Represents a notification saved in Cthulhu's history."""
message: str
timestamp: float
source: str = "generic"
source_generation: int = 0
notification_id: Optional[int] = None
live: bool = False
actions: Dict[str, str] = field(default_factory=dict)
app_name: str = ""
summary: str = ""
body: str = ""
urgency: int = -1
desktop_entry: str = ""
class NotificationPresenter:
"""Provides access to the notification history."""
def __init__(self) -> None:
self._gui: Optional[Any] = None # Optional[Gtk.Window]
self._gui: Optional[Any] = None
self._mako_monitor: Optional[Any] = None
self._handlers: Dict[str, Callable] = self._setup_handlers()
self._bindings: keybindings.KeyBindings = self._setup_bindings()
self._max_size: int = 55
@@ -63,7 +76,7 @@ class NotificationPresenter:
# the list. The current index is relative to, and used directly, with the
# python list, i.e. self._notifications[-3] would return the third-to-last
# notification message.
self._notifications: List[List[Any]] = [] # List of [message: str, time: float]
self._notifications: List[NotificationEntry] = []
self._current_index: int = -1
def get_bindings(self) -> keybindings.KeyBindings:
@@ -76,14 +89,47 @@ class NotificationPresenter:
return self._handlers
def save_notification(self, message: str) -> None:
def set_mako_monitor(self, monitor: Any) -> None:
"""Associates the mako D-Bus monitor with this presenter."""
self._mako_monitor = monitor
def save_notification(
self,
message: str,
source: str = "generic",
source_generation: int = 0,
notification_id: Optional[int] = None,
live: bool = False,
actions: Optional[Dict[str, str]] = None,
app_name: str = "",
summary: str = "",
body: str = "",
urgency: int = -1,
desktop_entry: str = "",
) -> NotificationEntry:
"""Adds message to the list of notification messages."""
tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
to_remove = max(len(self._notifications) - self._max_size + 1, 0)
self._notifications = self._notifications[to_remove:]
self._notifications.append([message, time.time()])
entry = NotificationEntry(
message=message,
timestamp=time.time(),
source=source,
source_generation=source_generation,
notification_id=notification_id,
live=live,
actions=dict(actions or {}),
app_name=app_name,
summary=summary,
body=body,
urgency=urgency,
desktop_entry=desktop_entry,
)
self._notifications.append(entry)
return entry
def clear_list(self) -> None:
"""Clears the notifications list."""
@@ -93,30 +139,117 @@ class NotificationPresenter:
self._notifications = []
self._current_index = -1
def refresh_live_notifications(self) -> bool:
"""Refreshes live mako state without announcing new notifications."""
if self._mako_monitor is None:
return False
return self._mako_monitor.refresh(announce_new=False)
def sync_live_notifications(
self,
source: str,
live_notifications: Dict[int, Dict[str, Any]],
source_generation: int,
) -> None:
"""Synchronizes current live notification metadata with history."""
live_ids = set(live_notifications)
for entry in self._notifications:
if entry.source != source or entry.source_generation != source_generation:
continue
if entry.notification_id in live_ids:
metadata = live_notifications[entry.notification_id]
entry.live = True
entry.message = metadata.get("message", entry.message)
entry.actions = dict(metadata.get("actions") or {})
entry.app_name = metadata.get("app_name", entry.app_name)
entry.summary = metadata.get("summary", entry.summary)
entry.body = metadata.get("body", entry.body)
entry.urgency = metadata.get("urgency", entry.urgency)
entry.desktop_entry = metadata.get("desktop_entry", entry.desktop_entry)
elif entry.live:
entry.live = False
def mark_source_unavailable(self, source: str) -> None:
"""Marks all live notifications from source as no longer live."""
for entry in self._notifications:
if entry.source == source and entry.live:
entry.live = False
def can_control_entry(self, entry: Optional[NotificationEntry]) -> bool:
"""Returns True if entry can still be controlled through mako."""
if entry is None or not entry.live or entry.notification_id is None:
return False
if self._mako_monitor is None:
return False
return self._mako_monitor.is_current_entry(entry)
def get_actions_for_entry(self, entry: Optional[NotificationEntry]) -> Dict[str, str]:
"""Returns the live action map for entry, if it is controllable."""
if not self.can_control_entry(entry):
return {}
return dict(entry.actions)
def dismiss_entry(self, script: Any, entry: Optional[NotificationEntry]) -> bool:
"""Dismisses the live notification represented by entry."""
if not self.can_control_entry(entry) or self._mako_monitor is None:
return False
result = self._mako_monitor.dismiss_notification(entry.notification_id)
if result:
script.presentMessage(messages.NOTIFICATION_DISMISSED)
return result
def invoke_action_for_entry(
self,
script: Any,
entry: Optional[NotificationEntry],
action_key: str,
) -> bool:
"""Invokes action_key for the live notification represented by entry."""
if not self.can_control_entry(entry) or self._mako_monitor is None:
return False
result = self._mako_monitor.invoke_action(entry.notification_id, action_key)
if result:
script.presentMessage(messages.NOTIFICATION_ACTION_INVOKED)
return result
def _setup_handlers(self) -> Dict[str, Callable]:
"""Sets up and returns the notification-presenter input event handlers."""
handlers = {}
handlers["present_last_notification"] = \
input_event.InputEventHandler(
self._present_last_notification,
cmdnames.NOTIFICATION_MESSAGES_LAST)
handlers["present_last_notification"] = input_event.InputEventHandler(
self._present_last_notification,
cmdnames.NOTIFICATION_MESSAGES_LAST,
)
handlers["present_next_notification"] = \
input_event.InputEventHandler(
self._present_next_notification,
cmdnames.NOTIFICATION_MESSAGES_NEXT)
handlers["present_next_notification"] = input_event.InputEventHandler(
self._present_next_notification,
cmdnames.NOTIFICATION_MESSAGES_NEXT,
)
handlers["present_previous_notification"] = \
input_event.InputEventHandler(
self._present_previous_notification,
cmdnames.NOTIFICATION_MESSAGES_PREVIOUS)
handlers["present_previous_notification"] = input_event.InputEventHandler(
self._present_previous_notification,
cmdnames.NOTIFICATION_MESSAGES_PREVIOUS,
)
handlers["show_notification_list"] = \
input_event.InputEventHandler(
self._show_notification_list,
cmdnames.NOTIFICATION_MESSAGES_LIST)
handlers["show_notification_list"] = input_event.InputEventHandler(
self._show_notification_list,
cmdnames.NOTIFICATION_MESSAGES_LIST,
)
return handlers
@@ -130,28 +263,36 @@ class NotificationPresenter:
"n",
keybindings.defaultModifierMask,
keybindings.CTHULHU_MODIFIER_MASK,
self._handlers.get("present_last_notification")))
self._handlers.get("present_last_notification"),
)
)
bindings.add(
keybindings.KeyBinding(
"",
keybindings.defaultModifierMask,
keybindings.NO_MODIFIER_MASK,
self._handlers.get("present_next_notification")))
self._handlers.get("present_next_notification"),
)
)
bindings.add(
keybindings.KeyBinding(
"",
keybindings.defaultModifierMask,
keybindings.NO_MODIFIER_MASK,
self._handlers.get("present_previous_notification")))
self._handlers.get("present_previous_notification"),
)
)
bindings.add(
keybindings.KeyBinding(
"n",
keybindings.defaultModifierMask,
keybindings.CTHULHU_CTRL_MODIFIER_MASK,
self._handlers.get("show_notification_list")))
self._handlers.get("show_notification_list"),
)
)
return bindings
@@ -171,6 +312,9 @@ class NotificationPresenter:
days = round(diff / 86400)
return messages.daysAgo(days)
def _entry_to_string(self, entry: NotificationEntry) -> str:
return f"{entry.message} {self._timestamp_to_string(entry.timestamp)}"
def _present_last_notification(self, script, event=None):
"""Presents the last notification."""
@@ -181,9 +325,8 @@ class NotificationPresenter:
msg = "NOTIFICATION PRESENTER: Presenting last notification."
debug.printMessage(debug.LEVEL_INFO, msg, True)
message, timestamp = self._notifications[-1]
string = f"{message} {self._timestamp_to_string(timestamp)}"
script.presentMessage(string)
entry = self._notifications[-1]
script.presentMessage(self._entry_to_string(entry))
self._current_index = -1
return True
@@ -200,23 +343,21 @@ class NotificationPresenter:
)
debug.printMessage(debug.LEVEL_INFO, msg, True)
# This is the first (oldest) message in the list.
if self._current_index == 0 :
if self._current_index == 0:
script.presentMessage(messages.NOTIFICATION_LIST_TOP)
message, timestamp = self._notifications[self._current_index]
entry = self._notifications[self._current_index]
else:
try:
index = self._current_index - 1
message, timestamp = self._notifications[index]
entry = self._notifications[index]
self._current_index -= 1
except IndexError:
msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
debug.printMessage(debug.LEVEL_INFO, msg, True)
script.presentMessage(messages.NOTIFICATION_LIST_TOP)
message, timestamp = self._notifications[self._current_index]
entry = self._notifications[self._current_index]
string = f"{message} {self._timestamp_to_string(timestamp)}"
script.presentMessage(string)
script.presentMessage(self._entry_to_string(entry))
return True
def _present_next_notification(self, script, event=None):
@@ -232,28 +373,27 @@ class NotificationPresenter:
)
debug.printMessage(debug.LEVEL_INFO, msg, True)
# This is the last (newest) message in the list.
if self._current_index == -1:
script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
message, timestamp = self._notifications[self._current_index]
entry = self._notifications[self._current_index]
else:
try:
index = self._current_index + 1
message, timestamp = self._notifications[index]
entry = self._notifications[index]
self._current_index += 1
except IndexError:
msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
debug.printMessage(debug.LEVEL_INFO, msg, True)
script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
message, timestamp = self._notifications[self._current_index]
entry = self._notifications[self._current_index]
string = f"{message} {self._timestamp_to_string(timestamp)}"
script.presentMessage(string)
script.presentMessage(self._entry_to_string(entry))
return True
def _show_notification_list(self, script, event=None):
"""Opens a dialog with a list of the notifications."""
self.refresh_live_notifications()
if not self._notifications:
script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
return True
@@ -261,39 +401,54 @@ class NotificationPresenter:
msg = "NOTIFICATION PRESENTER: Showing notification list."
debug.printMessage(debug.LEVEL_INFO, msg, True)
rows = [(message, self._timestamp_to_string(timestamp)) \
for message, timestamp in reversed(self._notifications)]
entries = list(reversed(self._notifications))
title = guilabels.notifications_count(len(self._notifications))
column_headers = [guilabels.NOTIFICATIONS_COLUMN_HEADER,
guilabels.NOTIFICATIONS_RECEIVED_TIME]
self._gui = NotificationListGUI(script, title, column_headers, rows)
column_headers = [
guilabels.NOTIFICATIONS_COLUMN_HEADER,
guilabels.NOTIFICATIONS_RECEIVED_TIME,
]
self._gui = NotificationListGUI(self, script, title, column_headers, entries)
self._gui.show_gui()
return True
def on_dialog_destroyed(self):
"""Handler for the 'destroyed' signal of the dialog."""
"""Handler for the dialog being destroyed."""
self._gui = None
class NotificationListGUI:
"""The dialog containing the notifications list."""
RESPONSE_COPY = 1
RESPONSE_ACTIONS = 2
RESPONSE_DISMISS = 3
def __init__(self, script, title, column_headers, rows):
def __init__(self, presenter, script, title, column_headers, entries):
self._presenter = presenter
self._script = script
self._model = None
self._tree = None
self._gui = self._create_dialog(title, column_headers, rows)
self._selection = None
self._dismiss_button = None
self._actions_button = None
self._gui = self._create_dialog(title, column_headers, entries)
def _create_dialog(self, title, column_headers, rows):
dialog = Gtk.Dialog(title,
None,
Gtk.DialogFlags.MODAL,
(Gtk.STOCK_COPY, self.RESPONSE_COPY,
Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY,
Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE))
def _create_dialog(self, title, column_headers, entries):
dialog = Gtk.Dialog(title, None, Gtk.DialogFlags.MODAL)
dialog.set_default_size(600, 400)
dialog.add_button(Gtk.STOCK_COPY, self.RESPONSE_COPY)
self._actions_button = dialog.add_button(
guilabels.NOTIFICATIONS_ACTIONS_BUTTON,
self.RESPONSE_ACTIONS,
)
self._dismiss_button = dialog.add_button(
guilabels.NOTIFICATIONS_DISMISS_BUTTON,
self.RESPONSE_DISMISS,
)
dialog.add_button(Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY)
dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
dialog.set_default_response(Gtk.ResponseType.CLOSE)
grid = Gtk.Grid()
content_area = dialog.get_content_area()
@@ -305,9 +460,12 @@ class NotificationListGUI:
tree = Gtk.TreeView()
tree.set_hexpand(True)
tree.set_vexpand(True)
tree_accessible = tree.get_accessible()
if tree_accessible:
tree_accessible.set_name(title)
scrolled_window.add(tree)
cols = len(column_headers) * [GObject.TYPE_STRING]
cols = (GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_PYOBJECT)
for i, header in enumerate(column_headers):
cell = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(header, cell, text=i)
@@ -316,18 +474,27 @@ class NotificationListGUI:
column.set_sort_column_id(i)
self._model = Gtk.ListStore(*cols)
for row in rows:
for entry in entries:
row_iter = self._model.append(None)
for i, cell in enumerate(row):
self._model.set_value(row_iter, i, cell)
self._model.set_value(row_iter, 0, entry.message)
self._model.set_value(
row_iter,
1,
self._presenter._timestamp_to_string(entry.timestamp),
)
self._model.set_value(row_iter, 2, entry)
tree.set_model(self._model)
selection = tree.get_selection()
selection.set_mode(Gtk.SelectionMode.SINGLE)
selection.connect("changed", self._on_selection_changed)
if self._model.iter_n_children(None) > 0:
selection.select_path(0)
self._selection = selection
self._tree = tree
dialog.connect("response", self.on_response)
dialog.connect("destroy", self._on_destroy)
self._update_action_buttons()
return dialog
def on_response(self, dialog, response):
@@ -341,9 +508,17 @@ class NotificationListGUI:
self._copy_selected_notification()
return
if response == self.RESPONSE_ACTIONS:
self._show_selected_actions()
return
if response == self.RESPONSE_DISMISS:
self._dismiss_selected_notification()
return
if response == Gtk.ResponseType.APPLY and self._model is not None:
self._model.clear()
getPresenter().clear_list()
self._presenter.clear_list()
self._script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
time.sleep(1)
self._gui.destroy()
@@ -357,40 +532,236 @@ class NotificationListGUI:
time_stamp = Gdk.CURRENT_TIME
self._gui.present_with_time(int(time_stamp))
def _copy_selected_notification(self):
if self._model is None or self._tree is None:
return
def _on_destroy(self, widget):
self._presenter.on_dialog_destroyed()
selection = self._tree.get_selection()
model, paths = selection.get_selected_rows()
def _on_selection_changed(self, selection):
self._presenter.refresh_live_notifications()
self._update_action_buttons()
def _get_selected_entry(self) -> Optional[NotificationEntry]:
if self._selection is None or self._model is None:
return None
model, paths = self._selection.get_selected_rows()
if not paths and self._model.iter_n_children(None) > 0:
selection.select_path(0)
model, paths = selection.get_selected_rows()
self._selection.select_path(0)
model, paths = self._selection.get_selected_rows()
if not paths:
return
return None
iter_for_path = model.get_iter(paths[0])
if iter_for_path is None:
return None
return model.get_value(iter_for_path, 2)
def _update_action_buttons(self) -> None:
entry = self._get_selected_entry()
can_control = self._presenter.can_control_entry(entry)
has_actions = bool(self._presenter.get_actions_for_entry(entry))
if self._dismiss_button is not None:
self._dismiss_button.set_sensitive(can_control)
if self._actions_button is not None:
self._actions_button.set_sensitive(can_control and has_actions)
def _copy_selected_notification(self):
entry = self._get_selected_entry()
if entry is None:
return
message = model.get_value(iter_for_path, 0)
timestamp = model.get_value(iter_for_path, 1)
timestamp = self._presenter._timestamp_to_string(entry.timestamp)
if timestamp:
text = f"{message}\t{timestamp}"
text = f"{entry.message}\t{timestamp}"
else:
text = f"{message}"
text = f"{entry.message}"
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(text, -1)
clipboard.store()
self._script.presentMessage(messages.CLIPBOARD_COPIED_FULL)
def _dismiss_selected_notification(self) -> None:
self._presenter.refresh_live_notifications()
entry = self._get_selected_entry()
if not self._presenter.can_control_entry(entry):
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
self._update_action_buttons()
return
if not self._presenter.dismiss_entry(self._script, entry):
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
self._update_action_buttons()
def _show_selected_actions(self) -> None:
self._presenter.refresh_live_notifications()
entry = self._get_selected_entry()
actions = self._presenter.get_actions_for_entry(entry)
if not self._presenter.can_control_entry(entry):
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
self._update_action_buttons()
return
if not actions:
self._script.presentMessage(messages.NOTIFICATION_NO_ACTIONS)
self._update_action_buttons()
return
dialog = NotificationActionsGUI(
self._gui,
self._script,
self._presenter,
entry,
actions,
)
dialog.show_gui()
class NotificationActionsGUI:
"""Dialog listing live mako actions for a notification."""
RESPONSE_INVOKE = 1
def __init__(
self,
parent: Gtk.Dialog,
script: Any,
presenter: NotificationPresenter,
entry: NotificationEntry,
actions: Dict[str, str],
) -> None:
self._parent = parent
self._script = script
self._presenter = presenter
self._entry = entry
self._actions = dict(actions)
self._list_box = None
self._invoke_button = None
self._dialog = self._create_dialog()
def _create_dialog(self):
dialog = Gtk.Dialog(
guilabels.NOTIFICATIONS_ACTIONS_TITLE,
self._parent,
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
)
dialog.set_default_size(420, 260)
self._invoke_button = dialog.add_button(
guilabels.NOTIFICATIONS_INVOKE_ACTION_BUTTON,
self.RESPONSE_INVOKE,
)
dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
content_area = dialog.get_content_area()
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
content_area.add(scrolled_window)
self._list_box = Gtk.ListBox()
self._list_box.set_selection_mode(Gtk.SelectionMode.SINGLE)
self._list_box.connect("row-activated", self._on_row_activated)
self._list_box.connect("selected-rows-changed", self._on_selection_changed)
list_accessible = self._list_box.get_accessible()
if list_accessible:
list_accessible.set_name(guilabels.NOTIFICATIONS_ACTIONS_TITLE)
scrolled_window.add(self._list_box)
for action_key, label_text in self._actions.items():
row = Gtk.ListBoxRow()
row._action_key = action_key # type: ignore[attr-defined]
label = Gtk.Label(label=label_text or action_key, xalign=0)
label.set_margin_start(10)
label.set_margin_end(10)
label.set_margin_top(6)
label.set_margin_bottom(6)
row.add(label)
self._list_box.add(row)
first_row = self._list_box.get_row_at_index(0)
if first_row is not None:
self._list_box.select_row(first_row)
dialog.connect("response", self._on_response)
self._update_invoke_button()
return dialog
def show_gui(self) -> None:
self._dialog.show_all()
time_stamp = Gtk.get_current_event_time()
if not time_stamp or time_stamp > 0xFFFFFFFF:
time_stamp = Gdk.CURRENT_TIME
self._dialog.present_with_time(int(time_stamp))
def _on_response(self, dialog, response) -> None:
if response == Gtk.ResponseType.CLOSE:
self._dialog.destroy()
return
if response == self.RESPONSE_INVOKE:
self._invoke_selected_action()
def _on_row_activated(self, list_box, row) -> None:
self._invoke_selected_action()
def _on_selection_changed(self, list_box) -> None:
self._update_invoke_button()
def _get_selected_action_key(self) -> Optional[str]:
if self._list_box is None:
return None
row = self._list_box.get_selected_row()
if row is None:
return None
return getattr(row, "_action_key", None)
def _update_invoke_button(self) -> None:
if self._invoke_button is None:
return
self._invoke_button.set_sensitive(self._get_selected_action_key() is not None)
def _invoke_selected_action(self) -> None:
self._presenter.refresh_live_notifications()
action_key = self._get_selected_action_key()
if not action_key:
return
if not self._presenter.can_control_entry(self._entry):
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
self._dialog.destroy()
return
if not self._presenter.invoke_action_for_entry(self._script, self._entry, action_key):
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
self._dialog.destroy()
_presenter = None
def getPresenter():
"""Returns the Notification Presenter"""
"""Returns the Notification Presenter."""
global _presenter
if _presenter is None:
_presenter = NotificationPresenter()
if _presenter._mako_monitor is None:
try:
from . import cthulhu
app = getattr(cthulhu, "cthulhuApp", None)
if app is not None:
monitor = getattr(app, "makoNotificationMonitor", None)
if monitor is not None:
_presenter.set_mako_monitor(monitor)
except Exception:
pass
return _presenter
+65
View File
@@ -0,0 +1,65 @@
import importlib.util
import sys
import types
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_ROOT = REPO_ROOT / "src"
BUILD_PACKAGE_ROOT = REPO_ROOT / "_build" / "src" / "cthulhu"
def _load_generated_module(module_name):
module_path = BUILD_PACKAGE_ROOT / f"{module_name}.py"
full_name = f"cthulhu.{module_name}"
if module_path.exists():
spec = importlib.util.spec_from_file_location(full_name, module_path)
module = importlib.util.module_from_spec(spec)
sys.modules[full_name] = module
spec.loader.exec_module(module)
return module
module = types.ModuleType(full_name)
if module_name == "cthulhu_i18n":
module._ = lambda text: text
module.ngettext = lambda singular, plural, count: singular if count == 1 else plural
module.cgettext = lambda text: text
module.C_ = lambda _context, text: text
module.localedir = str(REPO_ROOT / "po")
module.setModuleLocale = lambda *_args, **_kwargs: None
module.setLocaleForMessages = lambda *_args, **_kwargs: None
module.setLocaleForNames = lambda *_args, **_kwargs: None
module.setLocaleForGUI = lambda *_args, **_kwargs: None
return module
if module_name == "cthulhu_platform":
from cthulhu import cthulhuVersion
module.version = (
f"Cthulhu screen reader version "
f"{cthulhuVersion.version}-{cthulhuVersion.codeName}"
)
module.revision = ""
module.prefix = str(REPO_ROOT)
module.package = "cthulhu"
module.datadir = str(REPO_ROOT)
module.tablesdir = "/usr/share/liblouis/tables"
return module
raise ImportError(f"Unsupported generated module: {module_name}")
if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT))
import cthulhu # noqa: E402
for generated_module in ("cthulhu_i18n", "cthulhu_platform"):
if f"cthulhu.{generated_module}" in sys.modules:
continue
loaded_module = _load_generated_module(generated_module)
sys.modules[f"cthulhu.{generated_module}"] = loaded_module
setattr(cthulhu, generated_module, loaded_module)
+160
View File
@@ -0,0 +1,160 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import notification_presenter
from cthulhu.mako_notification_monitor import MakoNotificationMonitor
class _FakeScript:
def __init__(self):
self.speechGenerator = mock.Mock()
self.speechGenerator.voice.return_value = object()
self.speakMessage = mock.Mock()
self.displayBrailleMessage = mock.Mock()
class _FakeScriptManager:
def __init__(self, script):
self._script = script
def get_default_script(self):
return self._script
class _FakeApp:
def __init__(self, script):
self._script_manager = _FakeScriptManager(script)
def getScriptManager(self):
return self._script_manager
class MakoNotificationMonitorTests(unittest.TestCase):
def setUp(self):
self.presenter = notification_presenter.NotificationPresenter()
self.script = _FakeScript()
self.app = _FakeApp(self.script)
presenter_patcher = mock.patch(
"cthulhu.mako_notification_monitor.notification_presenter.getPresenter",
return_value=self.presenter,
)
self.addCleanup(presenter_patcher.stop)
presenter_patcher.start()
self.monitor = MakoNotificationMonitor(self.app)
self.monitor._generation = 1
def test_initial_seed_does_not_announce_existing_notifications(self):
self.monitor._sync_notifications(
[
{
"id": 10,
"app-name": "discord",
"summary": "hello",
"body": "world",
"actions": {"default": "View"},
}
],
announce_new=False,
)
self.assertEqual(self.monitor._known_ids, {10})
self.assertEqual(self.presenter._notifications, [])
self.script.speakMessage.assert_not_called()
self.script.displayBrailleMessage.assert_not_called()
def test_new_notification_is_spoken_and_saved(self):
self.monitor._sync_notifications(
[
{
"id": 10,
"app-name": "discord",
"summary": "old",
"body": "",
"actions": {},
}
],
announce_new=False,
)
self.monitor._sync_notifications(
[
{
"id": 10,
"app-name": "discord",
"summary": "old",
"body": "",
"actions": {},
},
{
"id": 11,
"app-name": "discord",
"summary": "new summary",
"body": "new body",
"actions": {"default": "View"},
},
],
announce_new=True,
)
self.assertEqual(len(self.presenter._notifications), 1)
entry = self.presenter._notifications[0]
self.assertEqual(entry.notification_id, 11)
self.assertTrue(entry.live)
self.assertEqual(entry.source, "mako")
self.assertEqual(entry.actions, {"default": "View"})
self.assertEqual(entry.message, "Notification new summary new body")
self.script.speakMessage.assert_called_once()
self.script.displayBrailleMessage.assert_called_once()
def test_removed_notification_is_marked_not_live(self):
self.monitor._sync_notifications(
[
{
"id": 11,
"app-name": "discord",
"summary": "new summary",
"body": "new body",
"actions": {"default": "View"},
},
],
announce_new=True,
)
self.assertTrue(self.presenter._notifications[0].live)
self.monitor._sync_notifications([], announce_new=False)
self.assertFalse(self.presenter._notifications[0].live)
self.assertEqual(self.monitor._known_ids, set())
def test_parse_notification_strips_markup_and_normalizes_actions(self):
parsed = self.monitor._parse_notification(
{
"id": 22,
"app-name": "Crash Reporting System",
"summary": "<b>Service Crash</b>",
"body": "<html><tt>/usr/bin/python3.14</tt> crashed</html>",
"desktop-entry": "crash-handler",
"urgency": 2,
"actions": {"1": "<b>Details</b>"},
}
)
self.assertEqual(parsed["notification_id"], 22)
self.assertEqual(parsed["summary"], "Service Crash")
self.assertEqual(parsed["body"], "/usr/bin/python3.14 crashed")
self.assertEqual(parsed["actions"], {"1": "Details"})
self.assertEqual(
parsed["message"],
"Notification Service Crash /usr/bin/python3.14 crashed",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,124 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import messages
from cthulhu.notification_presenter import NotificationPresenter
class _FakeMonitor:
def __init__(self):
self.dismissed = []
self.invoked = []
self.current_generation = 2
def refresh(self, announce_new=False):
return True
def is_current_entry(self, entry):
return entry.source == "mako" and entry.source_generation == self.current_generation
def dismiss_notification(self, notification_id):
self.dismissed.append(notification_id)
return True
def invoke_action(self, notification_id, action_key):
self.invoked.append((notification_id, action_key))
return True
class NotificationPresenterMakoTests(unittest.TestCase):
def setUp(self):
self.presenter = NotificationPresenter()
self.monitor = _FakeMonitor()
self.presenter.set_mako_monitor(self.monitor)
def test_sync_updates_current_generation_only(self):
old_entry = self.presenter.save_notification(
"Notification old",
source="mako",
source_generation=1,
notification_id=5,
live=False,
actions={"default": "Old"},
)
current_entry = self.presenter.save_notification(
"Notification current",
source="mako",
source_generation=2,
notification_id=6,
live=True,
actions={"default": "View"},
)
self.presenter.sync_live_notifications(
"mako",
{
5: {
"message": "Notification replaced",
"actions": {"default": "New"},
"app_name": "discord",
"summary": "replaced",
"body": "",
"urgency": 1,
"desktop_entry": "discord",
}
},
source_generation=2,
)
self.assertEqual(old_entry.message, "Notification old")
self.assertEqual(old_entry.actions, {"default": "Old"})
self.assertFalse(current_entry.live)
def test_can_control_entry_and_get_actions_require_current_monitor_generation(self):
entry = self.presenter.save_notification(
"Notification current",
source="mako",
source_generation=2,
notification_id=6,
live=True,
actions={"default": "View"},
)
self.assertTrue(self.presenter.can_control_entry(entry))
self.assertEqual(self.presenter.get_actions_for_entry(entry), {"default": "View"})
stale_entry = self.presenter.save_notification(
"Notification stale",
source="mako",
source_generation=1,
notification_id=7,
live=True,
actions={"default": "Open"},
)
self.assertFalse(self.presenter.can_control_entry(stale_entry))
self.assertEqual(self.presenter.get_actions_for_entry(stale_entry), {})
def test_dismiss_and_invoke_action_route_through_monitor(self):
entry = self.presenter.save_notification(
"Notification current",
source="mako",
source_generation=2,
notification_id=6,
live=True,
actions={"default": "View"},
)
script = mock.Mock()
self.assertTrue(self.presenter.dismiss_entry(script, entry))
self.assertEqual(self.monitor.dismissed, [6])
script.presentMessage.assert_called_with(messages.NOTIFICATION_DISMISSED)
script.reset_mock()
self.assertTrue(self.presenter.invoke_action_for_entry(script, entry, "default"))
self.assertEqual(self.monitor.invoked, [(6, "default")])
script.presentMessage.assert_called_with(messages.NOTIFICATION_ACTION_INVOKED)
if __name__ == "__main__":
unittest.main()