1/*
2 Copyright (C) 2005-2009 by Olivier Goffart <ogoffart at kde.org>
3 Copyright (C) 2008 by Dmitry Suzdalev <dimsuz@gmail.com>
4
5 This program is free software; you can redistribute it and/or modify
6 it under the terms of the GNU General Public License as published by
7 the Free Software Foundation; either version 2, or (at your option)
8 any later version.
9
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with this program; if not, write to the Free Software
17 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
19 */
20
21#include "notifybypopup.h"
22#include "imageconverter.h"
23#include "notifybypopupgrowl.h"
24
25#include <kdebug.h>
26#include <kpassivepopup.h>
27#include <kiconloader.h>
28#include <kdialog.h>
29#include <khbox.h>
30#include <kvbox.h>
31#include <kcharsets.h>
32#include <knotifyconfig.h>
33
34#include <QBuffer>
35#include <QImage>
36#include <QLabel>
37#include <QTextDocument>
38#include <QApplication>
39#include <QDesktopWidget>
40#include <QDBusConnection>
41#include <QDBusConnectionInterface>
42#include <QDBusServiceWatcher>
43#include <QXmlStreamReader>
44#include <kconfiggroup.h>
45
46static const char dbusServiceName[] = "org.freedesktop.Notifications";
47static const char dbusInterfaceName[] = "org.freedesktop.Notifications";
48static const char dbusPath[] = "/org/freedesktop/Notifications";
49
50NotifyByPopup::NotifyByPopup(QObject *parent)
51 : KNotifyPlugin(parent) , m_animationTimer(0), m_dbusServiceExists(false),
52 m_dbusServiceCapCacheDirty(true)
53{
54 QRect screen = QApplication::desktop()->availableGeometry();
55 m_nextPosition = screen.top();
56
57 // check if service already exists on plugin instantiation
58 QDBusConnectionInterface* interface = QDBusConnection::sessionBus().interface();
59 m_dbusServiceExists = interface && interface->isServiceRegistered(dbusServiceName);
60
61 if( m_dbusServiceExists )
62 slotServiceOwnerChanged(dbusServiceName, QString(), "_"); //connect signals
63
64 // to catch register/unregister events from service in runtime
65 QDBusServiceWatcher *watcher = new QDBusServiceWatcher(this);
66 watcher->setConnection(QDBusConnection::sessionBus());
67 watcher->setWatchMode(QDBusServiceWatcher::WatchForOwnerChange);
68 watcher->addWatchedService(dbusServiceName);
69 connect(watcher, SIGNAL(serviceOwnerChanged(const QString&, const QString&, const QString&)),
70 SLOT(slotServiceOwnerChanged(const QString&, const QString&, const QString&)));
71 if(!m_dbusServiceExists)
72 {
73 bool startfdo = false;
74#ifdef Q_WS_WIN
75 startfdo = true;
76#else
77 if (qgetenv("KDE_FULL_SESSION").isEmpty())
78 {
79 QDBusMessage message = QDBusMessage::createMethodCall("org.freedesktop.DBus",
80 "/org/freedesktop/DBus",
81 "org.freedesktop.DBus",
82 "ListActivatableNames");
83 QDBusReply<QStringList> reply = QDBusConnection::sessionBus().call(message);
84 if (reply.isValid() && reply.value().contains(dbusServiceName)) {
85 startfdo = true;
86 // We need to set m_dbusServiceExists to true because dbus might be too slow
87 // starting the service and the first call to NotifyByPopup::notify
88 // might not have had the service up, by setting this to true we
89 // guarantee it will still go through dbus and dbus will do the correct
90 // thing and wait for the service to go up
91 m_dbusServiceExists = true;
92 }
93 }
94#endif
95 if (startfdo)
96 QDBusConnection::sessionBus().interface()->startService(dbusServiceName);
97 }
98}
99
100
101NotifyByPopup::~NotifyByPopup()
102{
103 foreach(KPassivePopup *p,m_popups)
104 p->deleteLater();
105}
106
107void NotifyByPopup::notify( int id, KNotifyConfig * config )
108{
109 kDebug() << id << " active notifications:" << m_popups.keys() << m_idMap.keys();
110
111 if(m_popups.contains(id) || m_idMap.contains(id))
112 {
113 kDebug() << "the popup is already shown";
114 finish(id);
115 return;
116 }
117
118 // if Notifications DBus service exists on bus,
119 // it'll be used instead
120 if(m_dbusServiceExists)
121 {
122 if(!sendNotificationDBus(id, 0, config))
123 finish(id); //an error ocurred.
124 return;
125 }
126
127 // Default to 6 seconds if no timeout has been defined
128 int timeout = config->timeout == -1 ? 6000 : config->timeout;
129
130 // if Growl can display our popups, use that instead
131 if(NotifyByPopupGrowl::canPopup())
132 {
133 KNotifyConfig *c = ensurePopupCompatibility( config );
134
135 QString appCaption, iconName;
136 getAppCaptionAndIconName(c, &appCaption, &iconName);
137
138 KIconLoader iconLoader(iconName);
139 QPixmap appIcon = iconLoader.loadIcon( iconName, KIconLoader::Small );
140
141 NotifyByPopupGrowl::popup( &appIcon, timeout, appCaption, c->text );
142
143 // Finish immediately, because current NotifyByPopupGrowl can't callback
144 finish(id);
145 delete c;
146 return;
147 }
148
149 KPassivePopup *pop = new KPassivePopup( config->winId );
150 m_popups[id]=pop;
151 fillPopup(pop,id,config);
152 QRect screen = QApplication::desktop()->availableGeometry();
153 pop->setAutoDelete( true );
154 connect(pop, SIGNAL(destroyed()) , this, SLOT(slotPopupDestroyed()) );
155
156 pop->setTimeout(timeout);
157 pop->adjustSize();
158 pop->show(QPoint(screen.left() + screen.width()/2 - pop->width()/2 , m_nextPosition));
159 m_nextPosition+=pop->height();
160}
161
162void NotifyByPopup::slotPopupDestroyed( )
163{
164 const QObject *s=sender();
165 if(!s)
166 return;
167 QMap<int,KPassivePopup*>::iterator it;
168 for(it=m_popups.begin() ; it!=m_popups.end(); ++it )
169 {
170 QObject *o=it.value();
171 if(o && o == s)
172 {
173 finish(it.key());
174 m_popups.remove(it.key());
175 break;
176 }
177 }
178
179 //relocate popup
180 if(!m_animationTimer)
181 m_animationTimer = startTimer(10);
182}
183
184void NotifyByPopup::timerEvent(QTimerEvent * event)
185{
186 if(event->timerId() != m_animationTimer)
187 return KNotifyPlugin::timerEvent(event);
188
189 bool cont=false;
190 QRect screen = QApplication::desktop()->availableGeometry();
191 m_nextPosition = screen.top();
192 foreach(KPassivePopup *pop,m_popups)
193 {
194 int posy=pop->pos().y();
195 if(posy > m_nextPosition)
196 {
197 posy=qMax(posy-5,m_nextPosition);
198 m_nextPosition = posy + pop->height();
199 cont = cont || posy != m_nextPosition;
200 pop->move(pop->pos().x(),posy);
201 }
202 else
203 m_nextPosition += pop->height();
204 }
205 if(!cont)
206 {
207 killTimer(m_animationTimer);
208 m_animationTimer = 0;
209 }
210}
211
212void NotifyByPopup::slotLinkClicked( const QString &adr )
213{
214 unsigned int id=adr.section('/' , 0 , 0).toUInt();
215 unsigned int action=adr.section('/' , 1 , 1).toUInt();
216
217// kDebug() << id << " " << action;
218
219 if(id==0 || action==0)
220 return;
221
222 emit actionInvoked(id,action);
223}
224
225void NotifyByPopup::close( int id )
226{
227 delete m_popups.take(id);
228
229 if( m_dbusServiceExists)
230 {
231 closeNotificationDBus(id);
232 }
233}
234
235void NotifyByPopup::update(int id, KNotifyConfig * config)
236{
237 if (m_popups.contains(id))
238 {
239 KPassivePopup *p=m_popups[id];
240 fillPopup(p, id, config);
241 return;
242 }
243
244 // if Notifications DBus service exists on bus,
245 // it'll be used instead
246 if( m_dbusServiceExists)
247 {
248 sendNotificationDBus(id, id, config);
249 return;
250 }
251
252 // otherwise, just display a new Growl notification
253 if(NotifyByPopupGrowl::canPopup())
254 {
255 notify( id, config );
256 }
257}
258
259void NotifyByPopup::fillPopup(KPassivePopup *pop,int id,KNotifyConfig * config)
260{
261 QString appCaption, iconName;
262 getAppCaptionAndIconName(config, &appCaption, &iconName);
263
264 KIconLoader iconLoader(iconName);
265 QPixmap appIcon = iconLoader.loadIcon( iconName, KIconLoader::Small );
266
267 KVBox *vb = pop->standardView( config->title.isEmpty() ? appCaption : config->title , config->image.isNull() ? config->text : QString() , appIcon );
268 KVBox *vb2 = vb;
269
270 if(!config->image.isNull())
271 {
272 QPixmap pix = QPixmap::fromImage(config->image.toImage());
273 KHBox *hb = new KHBox(vb);
274 hb->setSpacing(KDialog::spacingHint());
275 QLabel *pil=new QLabel(hb);
276 pil->setPixmap( pix );
277 pil->setScaledContents(true);
278 if(pix.height() > 80 && pix.height() > pix.width() )
279 {
280 pil->setMaximumHeight(80);
281 pil->setMaximumWidth(80*pix.width()/pix.height());
282 }
283 else if(pix.width() > 80 && pix.height() <= pix.width())
284 {
285 pil->setMaximumWidth(80);
286 pil->setMaximumHeight(80*pix.height()/pix.width());
287 }
288 vb=new KVBox(hb);
289 QLabel *msg = new QLabel( config->text, vb );
290 msg->setAlignment( Qt::AlignLeft );
291 }
292
293
294 if ( !config->actions.isEmpty() )
295 {
296 QString linkCode=QString::fromLatin1("<p align=\"right\">");
297 int i=0;
298 foreach ( const QString & it , config->actions )
299 {
300 i++;
301 linkCode+=QString::fromLatin1("&nbsp;<a href=\"%1/%2\">%3</a> ").arg( id ).arg( i ).arg( Qt::escape(it) );
302 }
303 linkCode+=QString::fromLatin1("</p>");
304 QLabel *link = new QLabel(linkCode , vb );
305 link->setTextInteractionFlags(Qt::LinksAccessibleByMouse);
306 link->setOpenExternalLinks(false);
307 //link->setAlignment( AlignRight );
308 QObject::connect(link, SIGNAL(linkActivated(const QString &)), this, SLOT(slotLinkClicked(const QString& ) ) );
309 QObject::connect(link, SIGNAL(linkActivated(const QString &)), pop, SLOT(hide()));
310 }
311
312 pop->setView( vb2 );
313}
314
315void NotifyByPopup::slotServiceOwnerChanged( const QString & serviceName,
316 const QString & oldOwner, const QString & newOwner )
317{
318 kDebug() << serviceName << oldOwner << newOwner;
319 // tell KNotify that all existing notifications which it sent
320 // to DBus had been closed
321 foreach (int id, m_idMap.keys())
322 finished(id);
323 m_idMap.clear();
324
325 m_dbusServiceCapCacheDirty = true;
326 m_dbusServiceCapabilities.clear();
327
328 if(newOwner.isEmpty())
329 {
330 m_dbusServiceExists = false;
331 }
332 else if(oldOwner.isEmpty())
333 {
334 m_dbusServiceExists = true;
335
336 // connect to action invocation signals
337 bool connected = QDBusConnection::sessionBus().connect(QString(), // from any service
338 dbusPath,
339 dbusInterfaceName,
340 "ActionInvoked",
341 this,
342 SLOT(slotDBusNotificationActionInvoked(uint,const QString&)));
343 if (!connected) {
344 kWarning() << "warning: failed to connect to ActionInvoked dbus signal";
345 }
346
347 connected = QDBusConnection::sessionBus().connect(QString(), // from any service
348 dbusPath,
349 dbusInterfaceName,
350 "NotificationClosed",
351 this,
352 SLOT(slotDBusNotificationClosed(uint,uint)));
353 if (!connected) {
354 kWarning() << "warning: failed to connect to NotificationClosed dbus signal";
355 }
356 }
357}
358
359
360void NotifyByPopup::slotDBusNotificationActionInvoked(uint dbus_id, const QString& actKey)
361{
362 // find out knotify id
363 int id = m_idMap.key(dbus_id, 0);
364 if (id == 0) {
365 kDebug() << "failed to find knotify id for dbus_id" << dbus_id;
366 return;
367 }
368 kDebug() << "action" << actKey << "invoked for notification " << id;
369 // emulate link clicking
370 slotLinkClicked( QString("%1/%2").arg(id).arg(actKey) );
371 // now close notification - similar to popup behaviour
372 // (popups are hidden after link activation - see 'connects' of linkActivated signal above)
373 closeNotificationDBus(id);
374}
375
376void NotifyByPopup::slotDBusNotificationClosed(uint dbus_id, uint reason)
377{
378 Q_UNUSED(reason)
379 // find out knotify id
380 int id = m_idMap.key(dbus_id, 0);
381 kDebug() << dbus_id << " -> " << id;
382 if (id == 0) {
383 kDebug() << "failed to find knotify id for dbus_id" << dbus_id;
384 return;
385 }
386 // tell KNotify that this notification has been closed
387 m_idMap.remove(id);
388 finished(id);
389}
390
391void NotifyByPopup::getAppCaptionAndIconName(KNotifyConfig *config, QString *appCaption, QString *iconName)
392{
393 KConfigGroup globalgroup(&(*config->eventsfile), "Global");
394 *appCaption = globalgroup.readEntry("Name", globalgroup.readEntry("Comment", config->appname));
395
396 KConfigGroup eventGroup(&(*config->eventsfile), QString("Event/%1").arg(config->eventid));
397 if (eventGroup.hasKey("IconName")) {
398 *iconName = eventGroup.readEntry("IconName", config->appname);
399 } else {
400 *iconName = globalgroup.readEntry("IconName", config->appname);
401 }
402}
403
404bool NotifyByPopup::sendNotificationDBus(int id, int replacesId, KNotifyConfig* config_nocheck)
405{
406 // figure out dbus id to replace if needed
407 uint dbus_replaces_id = 0;
408 if (replacesId != 0 ) {
409 dbus_replaces_id = m_idMap.value(replacesId, 0);
410 if (!dbus_replaces_id)
411 return false; //the popup has been closed, there is nothing to replace.
412 }
413
414 QDBusMessage m = QDBusMessage::createMethodCall( dbusServiceName, dbusPath, dbusInterfaceName, "Notify" );
415
416 QList<QVariant> args;
417
418 QString appCaption, iconName;
419 getAppCaptionAndIconName(config_nocheck, &appCaption, &iconName);
420
421 KNotifyConfig *config = ensurePopupCompatibility( config_nocheck );
422
423 args.append( appCaption ); // app_name
424 args.append( dbus_replaces_id ); // replaces_id
425 args.append( iconName ); // app_icon
426 args.append( config->title.isEmpty() ? appCaption : config->title ); // summary
427 args.append( config->text ); // body
428 // galago spec defines action list to be list like
429 // (act_id1, action1, act_id2, action2, ...)
430 //
431 // assign id's to actions like it's done in fillPopup() method
432 // (i.e. starting from 1)
433 QStringList actionList;
434 int actId = 0;
435 foreach (const QString& actName, config->actions) {
436 actId++;
437 actionList.append(QString::number(actId));
438 actionList.append(actName);
439 }
440
441 args.append( actionList ); // actions
442
443 QVariantMap map;
444 // Add the application name to the hints.
445 // According to fdo spec, the app_name is supposed to be the applicaton's "pretty name"
446 // but in some places it's handy to know the application name itself
447 if (!config->appname.isEmpty()) {
448 map["x-kde-appname"] = config->appname;
449 }
450
451 // let's see if we've got an image, and store the image in the hints map
452 if (!config->image.isNull()) {
453 QImage image = config->image.toImage();
454 map["image_data"] = ImageConverter::variantForImage(image);
455 }
456
457 args.append( map ); // hints
458 args.append( config->timeout ); // expire timout
459
460 m.setArguments( args );
461 QDBusMessage replyMsg = QDBusConnection::sessionBus().call(m);
462
463 delete config;
464
465 if(replyMsg.type() == QDBusMessage::ReplyMessage) {
466 if (!replyMsg.arguments().isEmpty()) {
467 uint dbus_id = replyMsg.arguments().at(0).toUInt();
468 if (dbus_id == 0)
469 {
470 kDebug() << "error: dbus_id is null";
471 return false;
472 }
473 if (dbus_replaces_id && dbus_id == dbus_replaces_id)
474 return true;
475#if 1
476 int oldId = m_idMap.key(dbus_id, 0);
477 if (oldId != 0) {
478 kWarning() << "Received twice the same id "<< dbus_id << "( previous notification: " << oldId << ")";
479 m_idMap.remove(oldId);
480 finish(oldId);
481 }
482#endif
483 m_idMap.insert(id, dbus_id);
484 kDebug() << "mapping knotify id to dbus id:"<< id << "=>" << dbus_id;
485
486 return true;
487 } else {
488 kDebug() << "error: received reply with no arguments";
489 }
490 } else if (replyMsg.type() == QDBusMessage::ErrorMessage) {
491 kDebug() << "error: failed to send dbus message";
492 } else {
493 kDebug() << "unexpected reply type";
494 }
495 return false;
496}
497
498void NotifyByPopup::closeNotificationDBus(int id)
499{
500 uint dbus_id = m_idMap.take(id);
501 if (dbus_id == 0) {
502 kDebug() << "not found dbus id to close" << id;
503 return;
504 }
505
506 QDBusMessage m = QDBusMessage::createMethodCall( dbusServiceName, dbusPath,
507 dbusInterfaceName, "CloseNotification" );
508 QList<QVariant> args;
509 args.append( dbus_id );
510 m.setArguments( args );
511 bool queued = QDBusConnection::sessionBus().send(m);
512 if(!queued)
513 {
514 kDebug() << "warning: failed to queue dbus message";
515 }
516
517}
518
519QStringList NotifyByPopup::popupServerCapabilities()
520{
521 if (!m_dbusServiceExists) {
522 if( NotifyByPopupGrowl::canPopup() ) {
523 return NotifyByPopupGrowl::capabilities();
524 } else {
525 // Return capabilities of the KPassivePopup implementation
526 return QStringList() << "actions" << "body" << "body-hyperlinks"
527 << "body-markup" << "icon-static";
528 }
529 }
530
531 if(m_dbusServiceCapCacheDirty) {
532 QDBusMessage m = QDBusMessage::createMethodCall( dbusServiceName, dbusPath,
533 dbusInterfaceName, "GetCapabilities" );
534 QDBusMessage replyMsg = QDBusConnection::sessionBus().call(m);
535 if (replyMsg.type() != QDBusMessage::ReplyMessage) {
536 kWarning(300) << "Error while calling popup server GetCapabilities()";
537 return QStringList();
538 }
539
540 if (replyMsg.arguments().isEmpty()) {
541 kWarning(300) << "popup server GetCapabilities() returned an empty reply";
542 return QStringList();
543 }
544
545 m_dbusServiceCapabilities = replyMsg.arguments().at(0).toStringList();
546 m_dbusServiceCapCacheDirty = false;
547 }
548
549 return m_dbusServiceCapabilities;
550}
551
552
553KNotifyConfig *NotifyByPopup::ensurePopupCompatibility( const KNotifyConfig *config )
554{
555 KNotifyConfig *c = config->copy();
556 QStringList cap = popupServerCapabilities();
557
558 if( !cap.contains( "actions" ) )
559 {
560 c->actions.clear();
561 }
562
563 if( !cap.contains( "body-markup" ) )
564 {
565 if( c->title.startsWith( "<html>" ) )
566 c->title = stripHtml( config->title );
567 if( c->text.startsWith( "<html>" ) )
568 c->text = stripHtml( config->text );
569 }
570
571 return c;
572}
573
574QString NotifyByPopup::stripHtml( const QString &text )
575{
576 QXmlStreamReader r( "<elem>" + text + "</elem>" );
577 HtmlEntityResolver resolver;
578 r.setEntityResolver( &resolver );
579 QString result;
580 while( !r.atEnd() ) {
581 r.readNext();
582 if( r.tokenType() == QXmlStreamReader::Characters )
583 {
584 result.append( r.text() );
585 }
586 else if( r.tokenType() == QXmlStreamReader::StartElement
587 && r.name() == "br" )
588 {
589 result.append( "\n" );
590 }
591 }
592
593 if(r.hasError())
594 {
595 // XML error in the given text, just return the original string
596 kWarning(300) << "Notification to send to backend which does "
597 "not support HTML, contains invalid XML:"
598 << r.errorString() << "line" << r.lineNumber()
599 << "col" << r.columnNumber();
600 return text;
601 }
602
603 return result;
604}
605
606QString NotifyByPopup::HtmlEntityResolver::resolveUndeclaredEntity(
607 const QString &name )
608{
609 QString result =
610 QXmlStreamEntityResolver::resolveUndeclaredEntity(name);
611 if( !result.isEmpty() )
612 return result;
613
614 QChar ent = KCharsets::fromEntity( '&' + name );
615 if( ent.isNull() ) {
616 kWarning(300) << "Notification to send to backend which does "
617 "not support HTML, contains invalid entity: "
618 << name;
619 ent = ' ';
620 }
621 return QString(ent);
622}
623
624#include "notifybypopup.moc"
625