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

source code of knotifications/src/notifybypopup.cpp