1 /**
2  * SPDX-FileCopyrightText: 2015 Holger Kaelberer <holger.k@elberer.de>
3  *
4  * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
5  */
6 #include "notificationslistener.h"
7 
8 #include <QDBusInterface>
9 #include <QDBusArgument>
10 #include <QtDebug>
11 #include <QStandardPaths>
12 #include <QImage>
13 #include <KConfig>
14 #include <KConfigGroup>
15 #include <kiconloader.h>
16 #include <kicontheme.h>
17 
18 #include <core/device.h>
19 #include <core/kdeconnectplugin.h>
20 
21 #include <dbushelper.h>
22 
23 #include "sendnotificationsplugin.h"
24 #include "plugin_sendnotification_debug.h"
25 #include "notifyingapplication.h"
26 
27 //In older Qt released, qAsConst isnt available
28 #include "qtcompat_p.h"
29 
NotificationsListener(KdeConnectPlugin * aPlugin)30 NotificationsListener::NotificationsListener(KdeConnectPlugin* aPlugin)
31     : QDBusAbstractAdaptor(aPlugin),
32       m_plugin(aPlugin)
33 {
34     qRegisterMetaTypeStreamOperators<NotifyingApplication>("NotifyingApplication");
35 
36     bool ret = DBusHelper::sessionBus()
37                 .registerObject(QStringLiteral("/org/freedesktop/Notifications"),
38                                 this,
39                                 QDBusConnection::ExportScriptableContents);
40     if (!ret)
41         qCWarning(KDECONNECT_PLUGIN_SENDNOTIFICATION)
42                 << "Error registering notifications listener for device"
43                 << m_plugin->device()->name() << ":"
44                 << DBusHelper::sessionBus().lastError();
45     else
46         qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION)
47                 << "Registered notifications listener for device"
48                 << m_plugin->device()->name();
49 
50     QDBusInterface iface(QStringLiteral("org.freedesktop.DBus"), QStringLiteral("/org/freedesktop/DBus"),
51                          QStringLiteral("org.freedesktop.DBus"));
52     iface.call(QStringLiteral("AddMatch"),
53                QStringLiteral("interface='org.freedesktop.Notifications',member='Notify',type='method_call',eavesdrop='true'"));
54 
55     setTranslatedAppName();
56     loadApplications();
57 
58     connect(m_plugin->config(), &KdeConnectPluginConfig::configChanged, this, &NotificationsListener::loadApplications);
59 }
60 
~NotificationsListener()61 NotificationsListener::~NotificationsListener()
62 {
63     qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Destroying NotificationsListener";
64     QDBusInterface iface(QStringLiteral("org.freedesktop.DBus"), QStringLiteral("/org/freedesktop/DBus"),
65                          QStringLiteral("org.freedesktop.DBus"));
66     QDBusMessage res = iface.call(QStringLiteral("RemoveMatch"),
67                                   QStringLiteral("interface='org.freedesktop.Notifications',member='Notify',type='method_call',eavesdrop='true'"));
68     DBusHelper::sessionBus().unregisterObject(QStringLiteral("/org/freedesktop/Notifications"));
69 }
70 
setTranslatedAppName()71 void NotificationsListener::setTranslatedAppName()
72 {
73     QString filePath = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("knotifications5/kdeconnect.notifyrc"), QStandardPaths::LocateFile);
74     if (filePath.isEmpty()) {
75         qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Couldn't find kdeconnect.notifyrc to hide kdeconnect notifications on the devices. Using default name.";
76         m_translatedAppName = QStringLiteral("KDE Connect");
77         return;
78     }
79 
80     KConfig config(filePath, KConfig::OpenFlag::SimpleConfig);
81     KConfigGroup globalgroup(&config, QStringLiteral("Global"));
82     m_translatedAppName = globalgroup.readEntry(QStringLiteral("Name"), QStringLiteral("KDE Connect"));
83 }
84 
loadApplications()85 void NotificationsListener::loadApplications()
86 {
87     m_applications.clear();
88     const QVariantList list = m_plugin->config()->getList(QStringLiteral("applications"));
89     for (const auto& a : list) {
90         NotifyingApplication app = a.value<NotifyingApplication>();
91         if (!m_applications.contains(app.name)) {
92             m_applications.insert(app.name, app);
93         }
94     }
95     //qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Loaded" << applications.size() << " applications";
96 }
97 
parseImageDataArgument(const QVariant & argument,int & width,int & height,int & rowStride,int & bitsPerSample,int & channels,bool & hasAlpha,QByteArray & imageData) const98 bool NotificationsListener::parseImageDataArgument(const QVariant& argument,
99                                                    int& width, int& height,
100                                                    int& rowStride, int& bitsPerSample,
101                                                    int& channels, bool& hasAlpha,
102                                                    QByteArray& imageData) const
103 {
104     if (!argument.canConvert<QDBusArgument>())
105         return false;
106     const QDBusArgument dbusArg = argument.value<QDBusArgument>();
107     dbusArg.beginStructure();
108     dbusArg >> width >> height >> rowStride >> hasAlpha >> bitsPerSample
109             >> channels  >> imageData;
110     dbusArg.endStructure();
111     return true;
112 }
113 
iconForImageData(const QVariant & argument) const114 QSharedPointer<QIODevice> NotificationsListener::iconForImageData(const QVariant& argument) const
115 {
116     int width, height, rowStride, bitsPerSample, channels;
117     bool hasAlpha;
118     QByteArray imageData;
119 
120     if (!parseImageDataArgument(argument, width, height, rowStride, bitsPerSample,
121                                 channels, hasAlpha, imageData))
122         return QSharedPointer<QIODevice>();
123 
124     if (bitsPerSample != 8) {
125         qCWarning(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Unsupported image format:"
126                                                       << "width=" << width
127                                                       << "height=" << height
128                                                       << "rowStride=" << rowStride
129                                                       << "bitsPerSample=" << bitsPerSample
130                                                       << "channels=" << channels
131                                                       << "hasAlpha=" << hasAlpha;
132         return QSharedPointer<QIODevice>();
133     }
134 
135     QImage image(reinterpret_cast<uchar*>(imageData.data()), width, height, rowStride,
136                  hasAlpha ? QImage::Format_ARGB32 : QImage::Format_RGB32);
137     if (hasAlpha)
138         image = image.rgbSwapped();  // RGBA --> ARGB
139 
140     QSharedPointer<QBuffer> buffer = QSharedPointer<QBuffer>(new QBuffer);
141     if (!buffer || !buffer->open(QIODevice::WriteOnly) ||
142             !image.save(buffer.data(), "PNG")) {
143         qCWarning(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Could not initialize image buffer";
144         return QSharedPointer<QIODevice>();
145     }
146 
147     return buffer;
148 }
149 
iconForIconName(const QString & iconName) const150 QSharedPointer<QIODevice> NotificationsListener::iconForIconName(const QString& iconName) const
151 {
152     int size = KIconLoader::SizeEnormous;  // use big size to allow for good
153                                            // quality on high-DPI mobile devices
154     QString iconPath = KIconLoader::global()->iconPath(iconName, -size, true);
155     if (!iconPath.isEmpty()) {
156         if (!iconPath.endsWith(QLatin1String(".png")) &&
157                 KIconLoader::global()->theme()->name() != QLatin1String("hicolor")) {
158             // try falling back to hicolor theme:
159             KIconTheme hicolor(QStringLiteral("hicolor"));
160             if (hicolor.isValid()) {
161                 iconPath = hicolor.iconPath(iconName + QStringLiteral(".png"), size, KIconLoader::MatchBest);
162                 //qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Found non-png icon in default theme trying fallback to hicolor:" << iconPath;
163             }
164         }
165     }
166 
167     if (iconPath.endsWith(QLatin1String(".png")))
168         return QSharedPointer<QIODevice>(new QFile(iconPath));
169     return QSharedPointer<QIODevice>();
170 }
171 
Notify(const QString & appName,uint replacesId,const QString & appIcon,const QString & summary,const QString & body,const QStringList & actions,const QVariantMap & hints,int timeout)172 uint NotificationsListener::Notify(const QString& appName, uint replacesId,
173                                    const QString& appIcon,
174                                    const QString& summary, const QString& body,
175                                    const QStringList& actions,
176                                    const QVariantMap& hints, int timeout)
177 {
178     static int id = 0;
179     Q_UNUSED(actions);
180 
181     //qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Got notification appName=" << appName << "replacesId=" << replacesId << "appIcon=" << appIcon << "summary=" << summary << "body=" << body << "actions=" << actions << "hints=" << hints << "timeout=" << timeout;
182 
183     // skip our own notifications
184     if (appName == m_translatedAppName)
185         return 0;
186 
187     NotifyingApplication app;
188     if (!m_applications.contains(appName)) {
189         // new application -> add to config
190         app.name = appName;
191         app.icon = appIcon;
192         app.active = true;
193         app.blacklistExpression = QRegularExpression();
194         m_applications.insert(app.name, app);
195         // update config:
196         QVariantList list;
197         for (const auto& a : qAsConst(m_applications))
198             list << QVariant::fromValue<NotifyingApplication>(a);
199         m_plugin->config()->setList(QStringLiteral("applications"), list);
200         //qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Added new application to config:" << app;
201     } else {
202         app = m_applications.value(appName);
203     }
204 
205     if (!app.active)
206         return 0;
207 
208     if (timeout > 0 && m_plugin->config()->getBool(QStringLiteral("generalPersistent"), false))
209         return 0;
210 
211     int urgency = -1;
212     if (hints.contains(QStringLiteral("urgency"))) {
213         bool ok;
214         urgency = hints[QStringLiteral("urgency")].toInt(&ok);
215         if (!ok)
216             urgency = -1;
217     }
218     if (urgency > -1 && urgency < m_plugin->config()->getInt(QStringLiteral("generalUrgency"), 0))
219         return 0;
220 
221     QString ticker = summary;
222     if (!body.isEmpty() && m_plugin->config()->getBool(QStringLiteral("generalIncludeBody"), true))
223         ticker += QStringLiteral(": ") + body;
224 
225     if (app.blacklistExpression.isValid() &&
226             !app.blacklistExpression.pattern().isEmpty() &&
227             app.blacklistExpression.match(ticker).hasMatch())
228         return 0;
229 
230     //qCDebug(KDECONNECT_PLUGIN_SENDNOTIFICATION) << "Sending notification from" << appName << ":" <<ticker << "; appIcon=" << appIcon;
231     NetworkPacket np(PACKET_TYPE_NOTIFICATION, {
232         {QStringLiteral("id"), QString::number(replacesId > 0 ? replacesId : ++id)},
233         {QStringLiteral("appName"), appName},
234         {QStringLiteral("ticker"), ticker},
235         {QStringLiteral("isClearable"), timeout == 0}
236     });                                   // KNotifications are persistent if
237                                           // timeout == 0, for other notifications
238                                           // clearability is pointless
239 
240     // sync any icon data?
241     if (m_plugin->config()->getBool(QStringLiteral("generalSynchronizeIcons"), true)) {
242         QSharedPointer<QIODevice> iconSource;
243         // try different image sources according to priorities in notifications-
244         // spec version 1.2:
245         if (hints.contains(QStringLiteral("image-data")))
246             iconSource = iconForImageData(hints[QStringLiteral("image-data")]);
247         else if (hints.contains(QStringLiteral("image_data")))  // 1.1 backward compatibility
248             iconSource = iconForImageData(hints[QStringLiteral("image_data")]);
249         else if (hints.contains(QStringLiteral("image-path")))
250             iconSource = iconForIconName(hints[QStringLiteral("image-path")].toString());
251         else if (hints.contains(QStringLiteral("image_path")))  // 1.1 backward compatibility
252             iconSource = iconForIconName(hints[QStringLiteral("image_path")].toString());
253         else if (!appIcon.isEmpty())
254             iconSource = iconForIconName(appIcon);
255         else if (hints.contains(QStringLiteral("icon_data")))  // < 1.1 backward compatibility
256             iconSource = iconForImageData(hints[QStringLiteral("icon_data")]);
257 
258         if (iconSource)
259             np.setPayload(iconSource, iconSource->size());
260     }
261 
262     m_plugin->sendPacket(np);
263 
264     return (replacesId > 0 ? replacesId : id);
265 }
266