File indexing completed on 2024-04-28 15:29:13

0001 /*
0002     SPDX-FileCopyrightText: 2005-2009 Olivier Goffart <ogoffart at kde.org>
0003     SPDX-FileCopyrightText: 2008 Dmitry Suzdalev <dimsuz@gmail.com>
0004     SPDX-FileCopyrightText: 2014 Martin Klapetek <mklapetek@kde.org>
0005 
0006     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0007 */
0008 
0009 #include "notifybypopup.h"
0010 
0011 #include "debug_p.h"
0012 #include "imageconverter.h"
0013 #include "knotification.h"
0014 #include "knotificationreplyaction.h"
0015 
0016 #include <QBuffer>
0017 #include <QDBusConnection>
0018 #include <QGuiApplication>
0019 #include <QHash>
0020 #include <QMutableListIterator>
0021 #include <QPointer>
0022 #include <QUrl>
0023 
0024 #include <KConfigGroup>
0025 
0026 NotifyByPopup::NotifyByPopup(QObject *parent)
0027     : KNotificationPlugin(parent)
0028     , m_dbusInterface(QStringLiteral("org.freedesktop.Notifications"), QStringLiteral("/org/freedesktop/Notifications"), QDBusConnection::sessionBus())
0029 {
0030     m_dbusServiceCapCacheDirty = true;
0031 
0032     connect(&m_dbusInterface, &org::freedesktop::Notifications::ActionInvoked, this, &NotifyByPopup::onNotificationActionInvoked);
0033     connect(&m_dbusInterface, &org::freedesktop::Notifications::ActivationToken, this, &NotifyByPopup::onNotificationActionTokenReceived);
0034 
0035     // TODO can we check if this actually worked?
0036     // probably not as this just does a DBus filter which will work but the signal might still get caught in apparmor :/
0037     connect(&m_dbusInterface, &org::freedesktop::Notifications::NotificationReplied, this, &NotifyByPopup::onNotificationReplied);
0038 
0039     connect(&m_dbusInterface, &org::freedesktop::Notifications::NotificationClosed, this, &NotifyByPopup::onNotificationClosed);
0040 }
0041 
0042 NotifyByPopup::~NotifyByPopup()
0043 {
0044     if (!m_notificationQueue.isEmpty()) {
0045         qCWarning(LOG_KNOTIFICATIONS) << "Had queued notifications on destruction. Was the eventloop running?";
0046     }
0047 }
0048 
0049 void NotifyByPopup::notify(KNotification *notification, KNotifyConfig *notifyConfig)
0050 {
0051     notify(notification, *notifyConfig);
0052 }
0053 
0054 void NotifyByPopup::notify(KNotification *notification, const KNotifyConfig &notifyConfig)
0055 {
0056     if (m_dbusServiceCapCacheDirty) {
0057         // if we don't have the server capabilities yet, we need to query for them first;
0058         // as that is an async dbus operation, we enqueue the notification and process them
0059         // when we receive dbus reply with the server capabilities
0060         m_notificationQueue.append(qMakePair(notification, notifyConfig));
0061         queryPopupServerCapabilities();
0062     } else {
0063         if (!sendNotificationToServer(notification, notifyConfig)) {
0064             finish(notification); // an error occurred.
0065         }
0066     }
0067 }
0068 
0069 void NotifyByPopup::update(KNotification *notification, KNotifyConfig *notifyConfig)
0070 {
0071     update(notification, *notifyConfig);
0072 }
0073 
0074 void NotifyByPopup::update(KNotification *notification, const KNotifyConfig &notifyConfig)
0075 {
0076     sendNotificationToServer(notification, notifyConfig, true);
0077 }
0078 
0079 void NotifyByPopup::close(KNotification *notification)
0080 {
0081     QMutableListIterator<QPair<KNotification *, KNotifyConfig>> iter(m_notificationQueue);
0082     while (iter.hasNext()) {
0083         auto &item = iter.next();
0084         if (item.first == notification) {
0085             iter.remove();
0086         }
0087     }
0088 
0089     uint id = m_notifications.key(notification, 0);
0090 
0091     if (id == 0) {
0092         qCDebug(LOG_KNOTIFICATIONS) << "not found dbus id to close" << notification->id();
0093         return;
0094     }
0095 
0096     m_dbusInterface.CloseNotification(id);
0097 }
0098 
0099 void NotifyByPopup::onNotificationActionTokenReceived(uint notificationId, const QString &xdgActivationToken)
0100 {
0101     auto iter = m_notifications.find(notificationId);
0102     if (iter == m_notifications.end()) {
0103         return;
0104     }
0105 
0106     KNotification *n = *iter;
0107     if (n) {
0108         Q_EMIT xdgActivationTokenReceived(n->id(), xdgActivationToken);
0109     }
0110 }
0111 
0112 void NotifyByPopup::onNotificationActionInvoked(uint notificationId, const QString &actionKey)
0113 {
0114     auto iter = m_notifications.find(notificationId);
0115     if (iter == m_notifications.end()) {
0116         return;
0117     }
0118 
0119     KNotification *n = *iter;
0120     if (n) {
0121         if (actionKey == QLatin1String("default") && !n->defaultAction().isEmpty()) {
0122             Q_EMIT actionInvoked(n->id(), 0);
0123         } else if (actionKey == QLatin1String("inline-reply") && n->replyAction()) {
0124             Q_EMIT replied(n->id(), QString());
0125         } else {
0126             bool ok;
0127             const int actionIndex = actionKey.toInt(&ok);
0128 
0129             if (!ok || actionIndex < 1 || actionIndex > n->actions().size()) {
0130                 qCWarning(LOG_KNOTIFICATIONS) << "Ignored invalid action key" << actionKey;
0131             } else {
0132                 Q_EMIT actionInvoked(n->id(), actionIndex);
0133             }
0134         }
0135     } else {
0136         m_notifications.erase(iter);
0137     }
0138 }
0139 
0140 void NotifyByPopup::onNotificationClosed(uint dbus_id, uint reason)
0141 {
0142     auto iter = m_notifications.find(dbus_id);
0143     if (iter == m_notifications.end()) {
0144         return;
0145     }
0146     KNotification *n = *iter;
0147     m_notifications.remove(dbus_id);
0148 
0149     if (n) {
0150         Q_EMIT finished(n);
0151         // The popup bubble is the only user facing part of a notification,
0152         // if the user closes the popup, it means he wants to get rid
0153         // of the notification completely, including playing sound etc
0154         // Therefore we close the KNotification completely after closing
0155         // the popup, but only if the reason is 2, which means "user closed"
0156         if (reason == 2) {
0157             n->close();
0158         }
0159     }
0160 }
0161 
0162 void NotifyByPopup::onNotificationReplied(uint notificationId, const QString &text)
0163 {
0164     auto iter = m_notifications.find(notificationId);
0165     if (iter == m_notifications.end()) {
0166         return;
0167     }
0168 
0169     KNotification *n = *iter;
0170     if (n) {
0171         if (n->replyAction()) {
0172             Q_EMIT replied(n->id(), text);
0173         }
0174     } else {
0175         m_notifications.erase(iter);
0176     }
0177 }
0178 
0179 void NotifyByPopup::getAppCaptionAndIconName(const KNotifyConfig &notifyConfig, QString *appCaption, QString *iconName)
0180 {
0181     KConfigGroup globalgroup(&(*notifyConfig.eventsfile), QStringLiteral("Global"));
0182     *appCaption = globalgroup.readEntry("Name", globalgroup.readEntry("Comment", notifyConfig.appname));
0183 
0184     KConfigGroup eventGroup(&(*notifyConfig.eventsfile), QStringLiteral("Event/%1").arg(notifyConfig.eventid));
0185     if (eventGroup.hasKey("IconName")) {
0186         *iconName = eventGroup.readEntry("IconName", notifyConfig.appname);
0187     } else {
0188         *iconName = globalgroup.readEntry("IconName", notifyConfig.appname);
0189     }
0190 }
0191 
0192 bool NotifyByPopup::sendNotificationToServer(KNotification *notification, const KNotifyConfig &notifyConfig_nocheck, bool update)
0193 {
0194     uint updateId = m_notifications.key(notification, 0);
0195 
0196     if (update) {
0197         if (updateId == 0) {
0198             // we have nothing to update; the notification we're trying to update
0199             // has been already closed
0200             return false;
0201         }
0202     }
0203 
0204     QString appCaption;
0205     QString iconName;
0206     getAppCaptionAndIconName(notifyConfig_nocheck, &appCaption, &iconName);
0207 
0208     // did the user override the icon name?
0209     if (!notification->iconName().isEmpty()) {
0210         iconName = notification->iconName();
0211     }
0212 
0213     QString title = notification->title().isEmpty() ? appCaption : notification->title();
0214     QString text = notification->text();
0215 
0216     if (!m_popupServerCapabilities.contains(QLatin1String("body-markup"))) {
0217         text = stripRichText(text);
0218     }
0219 
0220     QVariantMap hintsMap;
0221 
0222     // freedesktop.org spec defines action list to be list like
0223     // (act_id1, action1, act_id2, action2, ...)
0224     //
0225     // assign id's to actions like it's done in fillPopup() method
0226     // (i.e. starting from 1)
0227     QStringList actionList;
0228     if (m_popupServerCapabilities.contains(QLatin1String("actions"))) {
0229         QString defaultAction = notification->defaultAction();
0230         if (!defaultAction.isEmpty()) {
0231             actionList.append(QStringLiteral("default"));
0232             actionList.append(defaultAction);
0233         }
0234         int actId = 0;
0235         const auto listActions = notification->actions();
0236         for (const QString &actionName : listActions) {
0237             actId++;
0238             actionList.append(QString::number(actId));
0239             actionList.append(actionName);
0240         }
0241 
0242         if (auto *replyAction = notification->replyAction()) {
0243             const bool supportsInlineReply = m_popupServerCapabilities.contains(QLatin1String("inline-reply"));
0244 
0245             if (supportsInlineReply || replyAction->fallbackBehavior() == KNotificationReplyAction::FallbackBehavior::UseRegularAction) {
0246                 actionList.append(QStringLiteral("inline-reply"));
0247                 actionList.append(replyAction->label());
0248 
0249                 if (supportsInlineReply) {
0250                     if (!replyAction->placeholderText().isEmpty()) {
0251                         hintsMap.insert(QStringLiteral("x-kde-reply-placeholder-text"), replyAction->placeholderText());
0252                     }
0253                     if (!replyAction->submitButtonText().isEmpty()) {
0254                         hintsMap.insert(QStringLiteral("x-kde-reply-submit-button-text"), replyAction->submitButtonText());
0255                     }
0256                     if (replyAction->submitButtonIconName().isEmpty()) {
0257                         hintsMap.insert(QStringLiteral("x-kde-reply-submit-button-icon-name"), replyAction->submitButtonIconName());
0258                     }
0259                 }
0260             }
0261         }
0262     }
0263 
0264     // Add the application name to the hints.
0265     // According to freedesktop.org spec, the app_name is supposed to be the application's "pretty name"
0266     // but in some places it's handy to know the application name itself
0267     if (!notification->appName().isEmpty()) {
0268         hintsMap[QStringLiteral("x-kde-appname")] = notification->appName();
0269     }
0270 
0271     if (!notification->eventId().isEmpty()) {
0272         hintsMap[QStringLiteral("x-kde-eventId")] = notification->eventId();
0273     }
0274 
0275     if (notification->flags() & KNotification::SkipGrouping) {
0276         hintsMap[QStringLiteral("x-kde-skipGrouping")] = 1;
0277     }
0278 
0279     QString desktopFileName = QGuiApplication::desktopFileName();
0280     if (!desktopFileName.isEmpty()) {
0281         // handle apps which set the desktopFileName property with filename suffix,
0282         // due to unclear API dox (https://bugreports.qt.io/browse/QTBUG-75521)
0283         if (desktopFileName.endsWith(QLatin1String(".desktop"))) {
0284             desktopFileName.chop(8);
0285         }
0286         hintsMap[QStringLiteral("desktop-entry")] = desktopFileName;
0287     }
0288 
0289     int urgency = -1;
0290     switch (notification->urgency()) {
0291     case KNotification::DefaultUrgency:
0292         break;
0293     case KNotification::LowUrgency:
0294         urgency = 0;
0295         break;
0296     case KNotification::NormalUrgency:
0297         Q_FALLTHROUGH();
0298     // freedesktop.org m_notifications only know low, normal, critical
0299     case KNotification::HighUrgency:
0300         urgency = 1;
0301         break;
0302     case KNotification::CriticalUrgency:
0303         urgency = 2;
0304         break;
0305     }
0306 
0307     if (urgency > -1) {
0308         hintsMap[QStringLiteral("urgency")] = urgency;
0309     }
0310 
0311     const QVariantMap hints = notification->hints();
0312     for (auto it = hints.constBegin(); it != hints.constEnd(); ++it) {
0313         hintsMap[it.key()] = it.value();
0314     }
0315 
0316     // FIXME - re-enable/fix
0317     // let's see if we've got an image, and store the image in the hints map
0318     if (!notification->pixmap().isNull()) {
0319         QByteArray pixmapData;
0320         QBuffer buffer(&pixmapData);
0321         buffer.open(QIODevice::WriteOnly);
0322         notification->pixmap().save(&buffer, "PNG");
0323         buffer.close();
0324         hintsMap[QStringLiteral("image_data")] = ImageConverter::variantForImage(QImage::fromData(pixmapData));
0325     }
0326 
0327     // Persistent     => 0  == infinite timeout
0328     // CloseOnTimeout => -1 == let the server decide
0329     int timeout = (notification->flags() & KNotification::Persistent) ? 0 : -1;
0330 
0331     const QDBusPendingReply<uint> reply = m_dbusInterface.Notify(appCaption, updateId, iconName, title, text, actionList, hintsMap, timeout);
0332 
0333     // parent is set to the notification so that no-one ever accesses a dangling pointer on the notificationObject property
0334     QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply, notification);
0335 
0336     QObject::connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, notification](QDBusPendingCallWatcher *watcher) {
0337         watcher->deleteLater();
0338         QDBusPendingReply<uint> reply = *watcher;
0339         m_notifications.insert(reply.argumentAt<0>(), notification);
0340     });
0341 
0342     return true;
0343 }
0344 
0345 void NotifyByPopup::queryPopupServerCapabilities()
0346 {
0347     if (!m_dbusServiceCapCacheDirty) {
0348         return;
0349     }
0350 
0351     QDBusPendingReply<QStringList> call = m_dbusInterface.GetCapabilities();
0352 
0353     QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(call);
0354 
0355     QObject::connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *watcher) {
0356         watcher->deleteLater();
0357         const QDBusPendingReply<QStringList> reply = *watcher;
0358         const QStringList capabilities = reply.argumentAt<0>();
0359         m_popupServerCapabilities = capabilities;
0360         m_dbusServiceCapCacheDirty = false;
0361 
0362         // re-run notify() on all enqueued m_notifications
0363         for (const QPair<KNotification *, KNotifyConfig> &noti : std::as_const(m_notificationQueue)) {
0364             notify(noti.first, noti.second);
0365         }
0366 
0367         m_notificationQueue.clear();
0368     });
0369 }
0370 
0371 #include "moc_notifybypopup.cpp"