1/*
2 SPDX-FileCopyrightText: 2005-2009 Olivier Goffart <ogoffart at kde.org>
3 SPDX-FileCopyrightText: 2008 Dmitry Suzdalev <dimsuz@gmail.com>
4 SPDX-FileCopyrightText: 2014 Martin Klapetek <mklapetek@kde.org>
5
6 SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
7*/
8
9#include "notifybypopup.h"
10
11#include "debug_p.h"
12#include "imageconverter.h"
13#include "knotification.h"
14#include "knotificationreplyaction.h"
15
16#include <QDBusConnection>
17#include <QGuiApplication>
18#include <QHash>
19#include <QIcon>
20#include <QMutableListIterator>
21#include <QPointer>
22#include <QUrl>
23
24#include <KConfigGroup>
25
26NotifyByPopup::NotifyByPopup(QObject *parent)
27 : KNotificationPlugin(parent)
28 , m_dbusInterface(QStringLiteral("org.freedesktop.Notifications"), QStringLiteral("/org/freedesktop/Notifications"), QDBusConnection::sessionBus())
29{
30 m_dbusServiceCapCacheDirty = true;
31
32 connect(sender: &m_dbusInterface, signal: &org::freedesktop::Notifications::ActionInvoked, context: this, slot: &NotifyByPopup::onNotificationActionInvoked);
33 connect(sender: &m_dbusInterface, signal: &org::freedesktop::Notifications::ActivationToken, context: this, slot: &NotifyByPopup::onNotificationActionTokenReceived);
34
35 // TODO can we check if this actually worked?
36 // probably not as this just does a DBus filter which will work but the signal might still get caught in apparmor :/
37 connect(sender: &m_dbusInterface, signal: &org::freedesktop::Notifications::NotificationReplied, context: this, slot: &NotifyByPopup::onNotificationReplied);
38
39 connect(sender: &m_dbusInterface, signal: &org::freedesktop::Notifications::NotificationClosed, context: this, slot: &NotifyByPopup::onNotificationClosed);
40}
41
42NotifyByPopup::~NotifyByPopup()
43{
44 if (!m_notificationQueue.isEmpty()) {
45 qCWarning(LOG_KNOTIFICATIONS) << "Had queued notifications on destruction. Was the eventloop running?";
46 }
47}
48
49void NotifyByPopup::notify(KNotification *notification, const KNotifyConfig &notifyConfig)
50{
51 if (m_dbusServiceCapCacheDirty) {
52 // if we don't have the server capabilities yet, we need to query for them first;
53 // as that is an async dbus operation, we enqueue the notification and process them
54 // when we receive dbus reply with the server capabilities
55 m_notificationQueue.append(t: qMakePair(value1&: notification, value2: notifyConfig));
56 queryPopupServerCapabilities();
57 } else {
58 if (!sendNotificationToServer(notification, config: notifyConfig)) {
59 finish(notification); // an error occurred.
60 }
61 }
62}
63
64void NotifyByPopup::update(KNotification *notification, const KNotifyConfig &notifyConfig)
65{
66 sendNotificationToServer(notification, config: notifyConfig, update: true);
67}
68
69void NotifyByPopup::close(KNotification *notification)
70{
71 QMutableListIterator<QPair<KNotification *, KNotifyConfig>> iter(m_notificationQueue);
72 while (iter.hasNext()) {
73 auto &item = iter.next();
74 if (item.first == notification) {
75 iter.remove();
76 }
77 }
78
79 uint id = m_notifications.key(value: notification, defaultKey: 0);
80
81 if (id == 0) {
82 qCDebug(LOG_KNOTIFICATIONS) << "not found dbus id to close" << notification->id();
83 return;
84 }
85
86 m_dbusInterface.CloseNotification(id);
87}
88
89void NotifyByPopup::onNotificationActionTokenReceived(uint notificationId, const QString &xdgActivationToken)
90{
91 auto iter = m_notifications.find(key: notificationId);
92 if (iter == m_notifications.end()) {
93 return;
94 }
95
96 KNotification *n = *iter;
97 if (n) {
98 Q_EMIT xdgActivationTokenReceived(id: n->id(), token: xdgActivationToken);
99 }
100}
101
102void NotifyByPopup::onNotificationActionInvoked(uint notificationId, const QString &actionKey)
103{
104 auto iter = m_notifications.find(key: notificationId);
105 if (iter == m_notifications.end()) {
106 return;
107 }
108
109 KNotification *n = *iter;
110 if (n) {
111 if (actionKey == QLatin1String("inline-reply") && n->replyAction()) {
112 Q_EMIT replied(id: n->id(), text: QString());
113 } else {
114 Q_EMIT actionInvoked(id: n->id(), action: actionKey);
115 }
116 } else {
117 m_notifications.erase(it: iter);
118 }
119}
120
121void NotifyByPopup::onNotificationClosed(uint dbus_id, uint reason)
122{
123 auto iter = m_notifications.find(key: dbus_id);
124 if (iter == m_notifications.end()) {
125 return;
126 }
127 KNotification *n = *iter;
128 m_notifications.remove(key: dbus_id);
129
130 if (n) {
131 Q_EMIT finished(notification: n);
132 // The popup bubble is the only user facing part of a notification,
133 // if the user closes the popup, it means he wants to get rid
134 // of the notification completely, including playing sound etc
135 // Therefore we close the KNotification completely after closing
136 // the popup, but only if the reason is 2, which means "user closed"
137 if (reason == 2) {
138 n->close();
139 }
140 }
141}
142
143void NotifyByPopup::onNotificationReplied(uint notificationId, const QString &text)
144{
145 auto iter = m_notifications.find(key: notificationId);
146 if (iter == m_notifications.end()) {
147 return;
148 }
149
150 KNotification *n = *iter;
151 if (n) {
152 if (n->replyAction()) {
153 Q_EMIT replied(id: n->id(), text);
154 }
155 } else {
156 m_notifications.erase(it: iter);
157 }
158}
159
160void NotifyByPopup::getAppCaptionAndIconName(const KNotifyConfig &notifyConfig, QString *appCaption, QString *iconName)
161{
162 *appCaption = notifyConfig.readGlobalEntry(QStringLiteral("Name"));
163 if (appCaption->isEmpty()) {
164 *appCaption = notifyConfig.readGlobalEntry(QStringLiteral("Comment"));
165 }
166 if (appCaption->isEmpty()) {
167 *appCaption = notifyConfig.applicationName();
168 }
169
170 *iconName = notifyConfig.readEntry(QStringLiteral("IconName"));
171 if (iconName->isEmpty()) {
172 *iconName = notifyConfig.readGlobalEntry(QStringLiteral("IconName"));
173 }
174 if (iconName->isEmpty()) {
175 *iconName = qGuiApp->windowIcon().name();
176 }
177 if (iconName->isEmpty()) {
178 *iconName = notifyConfig.applicationName();
179 }
180}
181
182bool NotifyByPopup::sendNotificationToServer(KNotification *notification, const KNotifyConfig &notifyConfig_nocheck, bool update)
183{
184 uint updateId = m_notifications.key(value: notification, defaultKey: 0);
185
186 if (update) {
187 if (updateId == 0) {
188 // we have nothing to update; the notification we're trying to update
189 // has been already closed
190 return false;
191 }
192 }
193
194 QString appCaption;
195 QString iconName;
196 getAppCaptionAndIconName(notifyConfig: notifyConfig_nocheck, appCaption: &appCaption, iconName: &iconName);
197
198 // did the user override the icon name?
199 if (!notification->iconName().isEmpty()) {
200 iconName = notification->iconName();
201 }
202
203 QString title = notification->title().isEmpty() ? appCaption : notification->title();
204 QString text = notification->text();
205
206 if (!m_popupServerCapabilities.contains(str: QLatin1String("body-markup"))) {
207 text = stripRichText(s: text);
208 }
209
210 QVariantMap hintsMap;
211
212 // freedesktop.org spec defines action list to be list like
213 // (act_id1, action1, act_id2, action2, ...)
214 //
215 // assign id's to actions like it's done in fillPopup() method
216 // (i.e. starting from 1)
217 QStringList actionList;
218 if (m_popupServerCapabilities.contains(str: QLatin1String("actions"))) {
219 if (notification->defaultAction()) {
220 actionList.append(QStringLiteral("default"));
221 actionList.append(t: notification->defaultAction()->label());
222 }
223 const auto listActions = notification->actions();
224 for (const KNotificationAction *action : listActions) {
225 actionList.append(t: action->id());
226 actionList.append(t: action->label());
227 }
228
229 if (auto *replyAction = notification->replyAction()) {
230 const bool supportsInlineReply = m_popupServerCapabilities.contains(str: QLatin1String("inline-reply"));
231
232 if (supportsInlineReply || replyAction->fallbackBehavior() == KNotificationReplyAction::FallbackBehavior::UseRegularAction) {
233 actionList.append(QStringLiteral("inline-reply"));
234 actionList.append(t: replyAction->label());
235
236 if (supportsInlineReply) {
237 if (!replyAction->placeholderText().isEmpty()) {
238 hintsMap.insert(QStringLiteral("x-kde-reply-placeholder-text"), value: replyAction->placeholderText());
239 }
240 if (!replyAction->submitButtonText().isEmpty()) {
241 hintsMap.insert(QStringLiteral("x-kde-reply-submit-button-text"), value: replyAction->submitButtonText());
242 }
243 if (replyAction->submitButtonIconName().isEmpty()) {
244 hintsMap.insert(QStringLiteral("x-kde-reply-submit-button-icon-name"), value: replyAction->submitButtonIconName());
245 }
246 }
247 }
248 }
249 }
250
251 // Add the application name to the hints.
252 // According to freedesktop.org spec, the app_name is supposed to be the application's "pretty name"
253 // but in some places it's handy to know the application name itself
254 if (!notification->appName().isEmpty()) {
255 hintsMap[QStringLiteral("x-kde-appname")] = notification->appName();
256 }
257
258 if (!notification->eventId().isEmpty()) {
259 hintsMap[QStringLiteral("x-kde-eventId")] = notification->eventId();
260 }
261
262 if (notification->flags() & KNotification::SkipGrouping) {
263 hintsMap[QStringLiteral("x-kde-skipGrouping")] = 1;
264 }
265
266 QString desktopFileName = QGuiApplication::desktopFileName();
267 if (!desktopFileName.isEmpty()) {
268 // handle apps which set the desktopFileName property with filename suffix,
269 // due to unclear API dox (https://bugreports.qt.io/browse/QTBUG-75521)
270 if (desktopFileName.endsWith(s: QLatin1String(".desktop"))) {
271 desktopFileName.chop(n: 8);
272 }
273 hintsMap[QStringLiteral("desktop-entry")] = desktopFileName;
274 }
275
276 int urgency = -1;
277 switch (notification->urgency()) {
278 case KNotification::DefaultUrgency:
279 break;
280 case KNotification::LowUrgency:
281 urgency = 0;
282 break;
283 case KNotification::NormalUrgency:
284 Q_FALLTHROUGH();
285 // freedesktop.org m_notifications only know low, normal, critical
286 case KNotification::HighUrgency:
287 urgency = 1;
288 break;
289 case KNotification::CriticalUrgency:
290 urgency = 2;
291 break;
292 }
293
294 if (urgency > -1) {
295 hintsMap[QStringLiteral("urgency")] = urgency;
296 }
297
298 const QVariantMap hints = notification->hints();
299 for (auto it = hints.constBegin(); it != hints.constEnd(); ++it) {
300 hintsMap[it.key()] = it.value();
301 }
302
303 // let's see if we've got an image, and store the image in the hints map
304 if (!notification->pixmap().isNull()) {
305 hintsMap[QStringLiteral("image_data")] = ImageConverter::variantForImage(image: notification->pixmap().toImage());
306 }
307
308 // Persistent => 0 == infinite timeout
309 // CloseOnTimeout => -1 == let the server decide
310 int timeout = (notification->flags() & KNotification::Persistent) ? 0 : -1;
311
312 const QDBusPendingReply<uint> reply = m_dbusInterface.Notify(app_name: appCaption, replaces_id: updateId, app_icon: iconName, summary: title, body: text, actions: actionList, hints: hintsMap, timeout);
313
314 // parent is set to the notification so that no-one ever accesses a dangling pointer on the notificationObject property
315 QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply, notification);
316
317 QObject::connect(sender: watcher, signal: &QDBusPendingCallWatcher::finished, context: this, slot: [this, notification](QDBusPendingCallWatcher *watcher) {
318 watcher->deleteLater();
319 QDBusPendingReply<uint> reply = *watcher;
320 m_notifications.insert(key: reply.argumentAt<0>(), value: notification);
321 });
322
323 return true;
324}
325
326void NotifyByPopup::queryPopupServerCapabilities()
327{
328 if (!m_dbusServiceCapCacheDirty) {
329 return;
330 }
331
332 QDBusPendingReply<QStringList> call = m_dbusInterface.GetCapabilities();
333
334 QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(call);
335
336 QObject::connect(sender: watcher, signal: &QDBusPendingCallWatcher::finished, context: this, slot: [this](QDBusPendingCallWatcher *watcher) {
337 watcher->deleteLater();
338 const QDBusPendingReply<QStringList> reply = *watcher;
339 const QStringList capabilities = reply.argumentAt<0>();
340 m_popupServerCapabilities = capabilities;
341 m_dbusServiceCapCacheDirty = false;
342
343 // re-run notify() on all enqueued m_notifications
344 for (const QPair<KNotification *, KNotifyConfig> &noti : std::as_const(t&: m_notificationQueue)) {
345 notify(notification: noti.first, notifyConfig: noti.second);
346 }
347
348 m_notificationQueue.clear();
349 });
350}
351
352#include "moc_notifybypopup.cpp"
353

source code of knotifications/src/notifybypopup.cpp