diff --git a/data/pantheon.portal b/data/pantheon.portal index b9de94e3..b677a33e 100644 --- a/data/pantheon.portal +++ b/data/pantheon.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.pantheon -Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.AppChooser;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.ScreenCast +Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.AppChooser;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Notification;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.ScreenCast UseIn=pantheon diff --git a/src/Notification/ActionGroup.vala b/src/Notification/ActionGroup.vala new file mode 100644 index 00000000..01ae2ba7 --- /dev/null +++ b/src/Notification/ActionGroup.vala @@ -0,0 +1,148 @@ + +/** + * Handles all action logic for notifications. It automatically tracks currently available notifications + * and lists their available actions. On activation it will emit the action_invoked signal on the portal, or + * close the notification or launch the application. + */ +public class Notification.ActionGroup : Object, GLib.ActionGroup { + private const string TARGET_TYPE_STRING = "(sv)"; + public static VariantType target_type = new VariantType (TARGET_TYPE_STRING); + + public Portal portal { get; construct; } + + public ActionGroup (Portal portal) { + Object (portal: portal); + } + + construct { + portal.notifications.items_changed.connect (on_items_changed); + } + + private void on_items_changed (uint pos, uint removed, uint added) { + for (uint i = pos; i < pos + added; i++) { + var notification = (Notification) portal.notifications.get_item (i); + + foreach (var action in notification.list_actions ()) { + action_added (action); + } + } + + //TODO: Maybe handle remove + } + + public string[] list_actions () { + var builder = new StrvBuilder (); + + for (uint i = 0; i < portal.notifications.n_items; i++) { + var notification = (Notification) portal.notifications.get_item (i); + builder.addv (notification.list_actions ()); + } + + return builder.end (); + } + + public void activate_action (string name, Variant? target) { + var parts = name.split ("+", 3); + + if (parts.length != 3) { + warning ("Invalid action name: %s", name); + return; + } + + var internal_id = parts[0]; + var type = parts[1]; + var action_name = parts[2]; + + var notification = Notification.get_for_internal_id (internal_id); + + if (notification == null) { + warning ("Notification not found: %s", internal_id); + return; + } + + var app_id = notification.app_id; + var id = notification.id; + + switch (type) { + case Notification.ACTION_TYPE_ACTION: + if (target == null || !target.is_of_type (target_type)) { + warning ("Invalid action target for action %s", name); + return; + } + + string activation_token; + Variant action_target; + target.get ("(sv)", out activation_token, out action_target); + + Variant[] action_target_array; + action_target.get ("av", out action_target_array); + + var platform_data = new HashTable (str_hash, str_equal); + platform_data["activation-token"] = activation_token; + + var parameters = new Gee.LinkedList (); + + parameters.add_all_array (action_target_array); + parameters.add (platform_data); + + portal.action_invoked (app_id, id, action_name, parameters.to_array ()); + break; + + case Notification.ACTION_TYPE_INTERNAL: + switch (action_name) { + case Notification.ACTION_DEFAULT: + // launch + warning ("Launched"); + break; + + case Notification.ACTION_DISMISS: + break; + + default: + return; + } + break; + + default: + return; + } + + uint position; + if (portal.notifications.find (notification, out position)) { + portal.notifications.remove (position); + } + } + + public override bool query_action ( + string name, + out bool enabled, + out unowned VariantType parameter_type, + out unowned VariantType state_type, + out Variant state_hint, + out Variant state + ) { + enabled = true; + parameter_type = null; + state_type = null; + state_hint = null; + state = null; + + var parts = name.split ("+", 3); + + if (parts.length != 3) { + warning ("Invalid action name: %s", name); + return false; + } + + var notification = Notification.get_for_internal_id (parts[0]); + + if (notification == null) { + warning ("Notification not found: %s", parts[0]); + return false; + } + + return notification.query_action (name, out enabled, out parameter_type, out state_type, out state_hint, out state); + } + + public void change_action_state (string action_name, Variant value) { } +} diff --git a/src/Notification/DBus.vala b/src/Notification/DBus.vala new file mode 100644 index 00000000..c6498c3a --- /dev/null +++ b/src/Notification/DBus.vala @@ -0,0 +1,23 @@ +[DBus (name = "io.elementary.portal.NotificationProvider")] +public class Notification.DBusProvider : Object { + public signal void items_changed (uint pos, uint removed, uint added); + + [DBus (visible = false)] + public Portal portal { get; construct; } + + public DBusProvider (Portal portal) { + Object (portal: portal); + } + + construct { + portal.notifications.items_changed.connect ((pos, removed, added) => items_changed (pos, removed, added)); + } + + public uint get_n_items () throws DBusError, IOError { + return portal.notifications.n_items; + } + + public Notification.Data get_notification (uint index) throws DBusError, IOError { + return ((Notification) portal.notifications.get_item (index)).data; + } +} diff --git a/src/Notification/Notification.vala b/src/Notification/Notification.vala new file mode 100644 index 00000000..2f2e2473 --- /dev/null +++ b/src/Notification/Notification.vala @@ -0,0 +1,167 @@ + +public class Notification.Notification : GLib.Object { + public const string ACTION_TYPE_ACTION = "action"; + public const string ACTION_TYPE_INTERNAL = "internal"; + public const string ACTION_DISMISS = "dismiss"; + public const string ACTION_DEFAULT = "default"; + + private const string ACTION_FORMAT = "%s+action+%s"; // interal id, action name + private const string INTERNAL_ACTION_FORMAT = "%s+internal+%s"; // interal id, action name + + [Flags] + public enum DisplayHint { + TRANSIENT, + TRAY, + PERSISTENT, + HIDE_ON_LOCK_SCREEN, + HIDE_CONTENT_ON_LOCK_SCREEN, + SHOW_AS_NEW + } + + public struct Button { + public string label; + public string action_name; + public Variant action_target; + + public Button (string internal_id, HashTable data) { + if ("label" in data) { + label = data["label"].get_string (); + } + + if ("action" in data) { + action_name = ACTION_FORMAT.printf (internal_id, data["action"].get_string ()); + } + + if ("action-target" in data) { + action_target = new Variant[] { data["action-target"] }; + } else { + action_target = new Variant[] {}; + } + } + } + + public struct Data { + public int64 timestamp; + public HashTable raw_data; + public string app_id; + public string dismiss_action_name; + public string default_action_name; + public Variant default_action_target; + public Button[] buttons; + public DisplayHint display_hint; + + public Data (string internal_id, string _app_id, HashTable _raw_data) { + timestamp = new DateTime.now_utc ().to_unix (); + raw_data = _raw_data; + app_id = _app_id; + dismiss_action_name = INTERNAL_ACTION_FORMAT.printf (internal_id, "dismiss"); + + if ("default-action" in raw_data) { + default_action_name = ACTION_FORMAT.printf (internal_id, raw_data["default-action"].get_string ()); + + if ("default-action-target" in raw_data) { + default_action_target = new Variant[] { raw_data["default-action-target"] }; + } else { + default_action_target = new Variant[] {}; + } + } else { + default_action_name = INTERNAL_ACTION_FORMAT.printf (internal_id, "default"); + default_action_target = new Variant[] {}; + } + + if ("buttons" in raw_data) { + var raw_buttons = (HashTable[]) raw_data["buttons"]; + + buttons = new Button[raw_buttons.length]; + + for (int i = 0; i < raw_buttons.length; i++) { + buttons[i] = Button (internal_id, raw_buttons[i]); + } + } else { + buttons = new Button[0]; + } + + display_hint = 0; + } + } + + private static HashTable notifications_by_internal_id = new HashTable (str_hash, str_equal); + private static uint internal_ids = 0; + + public static Notification? get_for_internal_id (string internal_id) { + return notifications_by_internal_id[internal_id]; + } + + private Data _data; + public Data data { get { return _data; } construct { _data = value; } } + + public string internal_id { private get; construct; } + public string app_id { get; construct; } + public string id { get; construct; } + + public DisplayHint display_hint { get { return _data.display_hint; } } + + public Notification (string app_id, string id, HashTable raw_data) { + var internal_id = "%u".printf (internal_ids++); + Object (internal_id: internal_id, app_id: app_id, id: id, data: Data (internal_id, app_id, raw_data)); + } + + construct { + notifications_by_internal_id[internal_id] = this; + } + + ~Notification () { + notifications_by_internal_id.remove (internal_id); + } + + public string[] list_actions () { + string[] actions = new string[data.buttons.length + 2]; + + actions[0] = data.dismiss_action_name; + actions[1] = data.default_action_name; + + for (int i = 0; i < data.buttons.length; i++) { + actions[i + 2] = data.buttons[i].action_name; + } + + return actions; + } + + public bool query_action ( + string name, + out bool enabled, + out unowned VariantType parameter_type, + out unowned VariantType state_type, + out Variant state_hint, + out Variant state + ) { + enabled = true; + parameter_type = null; + state_type = null; + state_hint = null; + state = null; + + if (name == _data.dismiss_action_name) { + parameter_type = null; + return true; + } + + if (name == _data.default_action_name) { + parameter_type = ActionGroup.target_type; + return true; + } + + foreach (var button in _data.buttons) { + if (button.action_name == name) { + parameter_type = ActionGroup.target_type; + return true; + } + } + + return false; + } + + public void replace_timestamp (Notification old_notification) { + _data.timestamp = old_notification._data.timestamp; + } +} diff --git a/src/Notification/Portal.vala b/src/Notification/Portal.vala new file mode 100644 index 00000000..be64286a --- /dev/null +++ b/src/Notification/Portal.vala @@ -0,0 +1,95 @@ +// TEST CALL FOR DSPY: + +// ('io.elementary.mail.desktop', 'new-mail', {'title': <'New mail from John Doe'>, 'body': <'You have a new mail from John Doe. Click to read it.'>}) + +/** + * The notififcations portal consists of a few parts. Most importantly this class which exposes the portal + * api, tracks currently active notifications and holds the other parts. + * The {@link ActionGroup} handles all action logic for notifications. It automatically exposes all actions + * for all available notifications and handles the activation of these actions (by talking to #this). + * It's also exported on the bus for usage by the indicator. + * The {@link BubbleManager} is responsible for showing the notifications to the user in a bubble. + * The {@link DBusProvider} is responsible for exposing the notifications to the DBus for consumption by the indicator. + * Both {@link BubbleManager} and {@link DBusProvider} use the {@link actions} for all interaction (dismissing, activating actions). + */ +[DBus (name = "org.freedesktop.impl.portal.Notification")] +public class Notification.Portal : Object { + public const string ID_FORMAT = "%s:%s"; + + public signal void action_invoked (string app_id, string id, string action_name, Variant[] parameters); + + public HashTable supported_options { get; construct; } + public uint version { get; default = 2; } + + [DBus (visible = false)] + public DBusConnection connection { private get; construct; } + + [DBus (visible = false)] + public ListStore notifications { get; construct; } + + private ActionGroup actions; + private DBusProvider dbus_provider; + + public Portal (DBusConnection connection) { + Object (connection: connection); + } + + construct { + supported_options = new HashTable (str_hash, str_equal); + + notifications = new ListStore (typeof (Notification)); + actions = new ActionGroup (this); + + dbus_provider = new DBusProvider (this); + + try { + connection.register_object ("/io/elementary/portal/NotificationProvider", dbus_provider); + connection.export_action_group ("/io/elementary/portal/NotificationProvider", actions); + } catch (Error e) { + warning ("Failed to register provider: %s", e.message); + } + } + + public void add_notification (string app_id, string id, HashTable data) throws DBusError, IOError { + var notification = new Notification (app_id, id, data); + replace_notification (app_id, id, notification); + } + + public void remove_notification (string app_id, string id) throws DBusError, IOError { + replace_notification (app_id, id, null); + } + + /** + * Removes the given id and if not null replaces it with the given notification at the same position. + * If SHOW_AS_NEW is set in the display hint of the replacement, it will be added at the front instead of at the same position. + * If no notification with the given id is found, and the replacement is not null, the replacement will be added at the front. + */ + internal void replace_notification (string app_id, string id, Notification? replacement) { + if (id != "") { + for (uint i = 0; i < notifications.n_items; i++) { + var notification = (Notification) notifications.get_item (i); + if (notification.app_id != app_id || notification.id != id) { + continue; + } + + if (replacement == null) { // Just remove and return + notifications.remove (i); + return; + } else if (SHOW_AS_NEW in replacement.display_hint) { // Remove but don't return because we want to add the replacement as if it was a new notification + notifications.remove (i); + break; + } else { // Replace and return + replacement.replace_timestamp (notification); + notifications.splice (i, 1, { replacement }); + return; + } + } + } + + if (replacement != null) { + notifications.splice (0, 0, { replacement }); + } else { + warning ("This shouldn't be reached."); + } + } +} diff --git a/src/XdgDesktopPortalPantheon.vala b/src/XdgDesktopPortalPantheon.vala index 56056120..d2205e3e 100644 --- a/src/XdgDesktopPortalPantheon.vala +++ b/src/XdgDesktopPortalPantheon.vala @@ -41,6 +41,9 @@ private void on_bus_acquired (DBusConnection connection, string name) { connection.register_object ("/org/freedesktop/portal/desktop", new Background.Portal (connection)); debug ("Background Portal registered!"); + connection.register_object ("/org/freedesktop/portal/desktop", new Notification.Portal (connection)); + debug ("Notification Portal registered!"); + connection.register_object ("/org/freedesktop/portal/desktop", new Screenshot.Portal (connection)); debug ("Screenshot Portal registered!"); diff --git a/src/meson.build b/src/meson.build index f995b995..237afee7 100644 --- a/src/meson.build +++ b/src/meson.build @@ -8,6 +8,10 @@ executable( 'AppChooser/Portal.vala', 'Background/NotificationRequest.vala', 'Background/Portal.vala', + 'Notification/ActionGroup.vala', + 'Notification/DBus.vala', + 'Notification/Notification.vala', + 'Notification/Portal.vala', 'ScreenCast/MonitorTracker/Interface.vala', 'ScreenCast/MonitorTracker/Monitor.vala', 'ScreenCast/MonitorTracker/MonitorTracker.vala',