diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,11 @@ PURPOSE "Needed by geolocation data engine." ) +find_package(KF5PulseAudioQt) +set_package_properties(KF5PulseAudioQt PROPERTIES DESCRIPTION "Qt bindings for PulseAudio" + TYPE RECOMMENDED + PURPOSE "Needed so do not disturb mode when disable notification sounds") + find_package(KF5Kirigami2 ${KF5_MIN_VERSION} CONFIG) set_package_properties(KF5Kirigami2 PROPERTIES DESCRIPTION "A QtQuick based components set" @@ -148,6 +153,7 @@ add_subdirectory(appmenu) add_subdirectory(libtaskmanager) +add_subdirectory(libnotificationmanager) add_subdirectory(libcolorcorrect) add_subdirectory(components) diff --git a/dataengines/notifications/CMakeLists.txt b/dataengines/notifications/CMakeLists.txt --- a/dataengines/notifications/CMakeLists.txt +++ b/dataengines/notifications/CMakeLists.txt @@ -14,8 +14,6 @@ CATEGORY_NAME kde.dataengine.notifications` DEFAULT_SEVERITY Info) -qt5_add_dbus_adaptor( notifications_engine_SRCS org.freedesktop.Notifications.xml notificationsengine.h NotificationsEngine ) - add_library(plasma_engine_notifications MODULE ${notifications_engine_SRCS}) target_link_libraries(plasma_engine_notifications @@ -27,6 +25,7 @@ KF5::Plasma KF5::Service KF5::NotifyConfig + PW::LibNotificationManager ) kcoreaddons_desktop_to_json(plasma_engine_notifications plasma-dataengine-notifications.desktop) diff --git a/dataengines/notifications/notificationaction.cpp b/dataengines/notifications/notificationaction.cpp --- a/dataengines/notifications/notificationaction.cpp +++ b/dataengines/notifications/notificationaction.cpp @@ -19,10 +19,14 @@ #include "notificationaction.h" #include "notificationsengine.h" +#include "server.h" + #include #include "debug.h" +using namespace NotificationManager; + void NotificationAction::start() { qCDebug(NOTIFICATIONS) << "Trying to perform the action " << operationName() << " on " << destination(); @@ -50,7 +54,7 @@ if (operationName() == QLatin1String("invokeAction")) { qCDebug(NOTIFICATIONS) << "invoking action on " << id; - emit m_engine->ActionInvoked(id, parameters()[QStringLiteral("actionId")].toString()); + Server::self().invokeAction(id, parameters()[QStringLiteral("actionId")].toString()); } else if (operationName() == QLatin1String("userClosed")) { //userClosedNotification deletes the job, so we have to invoke it queued, in this case emitResult() can be called m_engine->metaObject()->invokeMethod(m_engine, "removeNotification", Qt::QueuedConnection, Q_ARG(uint, id), Q_ARG(uint, 2)); diff --git a/dataengines/notifications/notificationsengine.h b/dataengines/notifications/notificationsengine.h --- a/dataengines/notifications/notificationsengine.h +++ b/dataengines/notifications/notificationsengine.h @@ -25,6 +25,11 @@ #include #include +namespace NotificationManager +{ +class Notification; +} + struct NotificationInhibiton { QString hint; @@ -53,14 +58,8 @@ */ uint Notify(const QString &app_name, uint replaces_id, const QString &app_icon, const QString &summary, const QString &body, const QStringList &actions, const QVariantMap &hints, int timeout); - void CloseNotification( uint id ); - Plasma::Service* serviceForSource(const QString& source) override; - QStringList GetCapabilities(); - - QString GetServerInformation(QString& vendor, QString& version, QString& specVersion); - int createNotification(const QString &appName, const QString &appIcon, const QString &summary, const QString &body, int timeout, const QStringList &actions, const QVariantMap &hints); @@ -75,38 +74,12 @@ public Q_SLOTS: void removeNotification(uint id, uint closeReason); - bool registerDBusService(); - - void onBroadcastNotification(const QMap &properties); - -Q_SIGNALS: - void NotificationClosed( uint id, uint reason ); - void ActionInvoked( uint id, const QString& actionKey ); private: - /** - * Holds the id that will be assigned to the next notification source - * that will be created - */ - uint m_nextId; + void notificationAdded(const NotificationManager::Notification ¬ification); QHash m_activeNotifications; - QHash m_configurableApplications; - - /** - * A "blacklist" of apps for which always the previous notification from this app - * is replaced by the newer one. This is the case for eg. media players - * as we simply want to update the notification, not get spammed by tens - * of notifications for quickly changing songs in playlist - */ - QSet m_alwaysReplaceAppsList; - /** - * This holds the notifications sent from apps from the list above - * for fast lookup - */ - QHash m_notificationsFromReplaceableApp; - QList m_inhibitions; friend class NotificationAction; diff --git a/dataengines/notifications/notificationsengine.cpp b/dataengines/notifications/notificationsengine.cpp --- a/dataengines/notifications/notificationsengine.cpp +++ b/dataengines/notifications/notificationsengine.cpp @@ -22,252 +22,90 @@ #include "notificationsadaptor.h" #include "notificationsanitizer.h" +#include "server.h" +#include "notification.h" + +#include #include #include #include #include -#include #include -#include - #include #include #include -#include -#include - -// for ::kill -#include - #include "debug.h" +using namespace NotificationManager; + NotificationsEngine::NotificationsEngine( QObject* parent, const QVariantList& args ) - : Plasma::DataEngine( parent, args ), m_nextId( 1 ), m_alwaysReplaceAppsList({QStringLiteral("Clementine"), QStringLiteral("Spotify"), QStringLiteral("Amarok")}) + : Plasma::DataEngine( parent, args ) { - new NotificationsAdaptor(this); - - if (!registerDBusService()) { - QDBusConnection dbus = QDBusConnection::sessionBus(); - // Retrieve the pid of the current o.f.Notifications service - QDBusReply pidReply = dbus.interface()->servicePid(QStringLiteral("org.freedesktop.Notifications")); - uint pid = pidReply.value(); - // Check if it's not the same app as our own - if (pid != qApp->applicationPid()) { - QDBusReply plasmaPidReply = dbus.interface()->servicePid(QStringLiteral("org.kde.plasmashell")); - // It's not the same but check if it isn't plasma, - // we don't want to kill Plasma - if (pid != plasmaPidReply.value()) { - qCDebug(NOTIFICATIONS) << "Terminating current Notification service with pid" << pid; - // Now finally terminate the service and register our own - ::kill(pid, SIGTERM); - // Wait 3 seconds and then try registering it again - QTimer::singleShot(3000, this, &NotificationsEngine::registerDBusService); - } - } - } - KConfigGroup config(KSharedConfig::openConfig(), QStringLiteral("Notifications")); - const bool broadcastsEnabled = config.readEntry("ListenForBroadcasts", false); + connect(&Server::self(), &Server::notificationAdded, this, [this](const Notification ¬ification) { + notificationAdded(notification); + }); - if (broadcastsEnabled) { - qCDebug(NOTIFICATIONS) << "Notifications engine is configured to listen for broadcasts"; - QDBusConnection::systemBus().connect({}, {}, QStringLiteral("org.kde.BroadcastNotifications"), - QStringLiteral("Notify"), this, SLOT(onBroadcastNotification(QMap))); - } + connect(&Server::self(), &Server::notificationReplaced, this, [this](uint replacedId, const Notification ¬ification) { + // Notification will already have the correct identical ID + Q_UNUSED(replacedId); + notificationAdded(notification); + }); - // Read additional single-notification-popup-only from a config file - KConfig singlePopupConfig(QStringLiteral("plasma_single_popup_notificationrc")); - KConfigGroup singlePopupConfigGroup(&singlePopupConfig, "General"); - m_alwaysReplaceAppsList += QSet::fromList(singlePopupConfigGroup.readEntry("applications", QStringList())); + connect(&Server::self(), &Server::notificationRemoved, this, [this](uint id, Server::CloseReason reason) { + Q_UNUSED(reason); + const QString source = QStringLiteral("notification %1").arg(id); + // if we don't have that notification in our local list, + // it has already been closed so don't notify a second time + if (m_activeNotifications.remove(source) > 0) { + removeSource(source); + } + }); } NotificationsEngine::~NotificationsEngine() { - QDBusConnection dbus = QDBusConnection::sessionBus(); - dbus.unregisterService( QStringLiteral("org.freedesktop.Notifications") ); -} - -void NotificationsEngine::init() -{ -} - -bool NotificationsEngine::registerDBusService() -{ - QDBusConnection dbus = QDBusConnection::sessionBus(); - dbus.registerObject(QStringLiteral("/org/freedesktop/Notifications"), this); - bool so = dbus.registerService(QStringLiteral("org.freedesktop.Notifications")); - if (so) { - return true; - } - - qCInfo(NOTIFICATIONS) << "Failed to register Notifications service"; - return false; -} - -inline void copyLineRGB32(QRgb* dst, const char* src, int width) -{ - const char* end = src + width * 3; - for (; src != end; ++dst, src+=3) { - *dst = qRgb(src[0], src[1], src[2]); - } -} - -inline void copyLineARGB32(QRgb* dst, const char* src, int width) -{ - const char* end = src + width * 4; - for (; src != end; ++dst, src+=4) { - *dst = qRgba(src[0], src[1], src[2], src[3]); - } -} - -static QImage decodeNotificationSpecImageHint(const QDBusArgument& arg) -{ - int width, height, rowStride, hasAlpha, bitsPerSample, channels; - QByteArray pixels; - char* ptr; - char* end; - - arg.beginStructure(); - arg >> width >> height >> rowStride >> hasAlpha >> bitsPerSample >> channels >> pixels; - arg.endStructure(); - - #define SANITY_CHECK(condition) \ - if (!(condition)) { \ - qWarning() << "Sanity check failed on" << #condition; \ - return QImage(); \ - } - - SANITY_CHECK(width > 0); - SANITY_CHECK(width < 2048); - SANITY_CHECK(height > 0); - SANITY_CHECK(height < 2048); - SANITY_CHECK(rowStride > 0); - - #undef SANITY_CHECK - - QImage::Format format = QImage::Format_Invalid; - void (*fcn)(QRgb*, const char*, int) = nullptr; - if (bitsPerSample == 8) { - if (channels == 4) { - format = QImage::Format_ARGB32; - fcn = copyLineARGB32; - } else if (channels == 3) { - format = QImage::Format_RGB32; - fcn = copyLineRGB32; - } - } - if (format == QImage::Format_Invalid) { - qWarning() << "Unsupported image format (hasAlpha:" << hasAlpha << "bitsPerSample:" << bitsPerSample << "channels:" << channels << ")"; - return QImage(); - } - - QImage image(width, height, format); - ptr = pixels.data(); - end = ptr + pixels.length(); - for (int y=0; y end) { - qWarning() << "Image data is incomplete. y:" << y << "height:" << height; - break; - } - fcn((QRgb*)image.scanLine(y), ptr, width); - } - return image; } -static QString findImageForSpecImagePath(const QString &_path) +void NotificationsEngine::init() { - QString path = _path; - if (path.startsWith(QLatin1String("file:"))) { - QUrl url(path); - path = url.toLocalFile(); - } - return KIconLoader::global()->iconPath(path, -KIconLoader::SizeHuge, - true /* canReturnNull */); } -uint NotificationsEngine::Notify(const QString &app_name, uint replaces_id, - const QString &app_icon, const QString &summary, const QString &body, - const QStringList &actions, const QVariantMap &hints, int timeout) +void NotificationsEngine::notificationAdded(const Notification ¬ification) { - foreach(NotificationInhibiton *ni, m_inhibitions) { - if (hints[ni->hint] == ni->value) { - qCDebug(NOTIFICATIONS) << "notification inhibited. Skipping"; - return -1; - } - } + const QString app_name = notification.applicationName(); + const QString appRealName = notification.notifyRcName(); + const QString eventId = notification.eventId(); // FIXME = hints[QStringLiteral("x-kde-eventId")].toString(); + const QStringList urls = QUrl::toStringList(notification.urls()); + const QString desktopEntry = notification.desktopEntry(); + const QString summary = notification.summary(); - uint partOf = 0; - const QString appRealName = hints[QStringLiteral("x-kde-appname")].toString(); - const QString eventId = hints[QStringLiteral("x-kde-eventId")].toString(); - const bool skipGrouping = hints[QStringLiteral("x-kde-skipGrouping")].toBool(); - const QStringList &urls = hints[QStringLiteral("x-kde-urls")].toStringList(); - const QString &desktopEntry = hints[QStringLiteral("desktop-entry")].toString(); - - // group notifications that have the same title coming from the same app - // or if they are on the "blacklist", honor the skipGrouping hint sent - if (!replaces_id && m_activeNotifications.values().contains(app_name + summary) && !skipGrouping && urls.isEmpty() && !m_alwaysReplaceAppsList.contains(app_name)) { - // cut off the "notification " from the source name - partOf = m_activeNotifications.key(app_name + summary).midRef(13).toUInt(); - } + QString bodyFinal = notification.body(); // is already sanitized by NotificationManager + QString summaryFinal = notification.summary(); + int timeout = notification.timeout(); - qCDebug(NOTIFICATIONS) << "Currrent active notifications:" << m_activeNotifications; - qCDebug(NOTIFICATIONS) << "Guessing partOf as:" << partOf; - qCDebug(NOTIFICATIONS) << " New Notification: " << summary << body << timeout << "& Part of:" << partOf; - QString bodyFinal = NotificationSanitizer::parse(body); - QString summaryFinal = summary; - - if (partOf > 0) { - const QString source = QStringLiteral("notification %1").arg(partOf); - Plasma::DataContainer *container = containerForSource(source); - if (container) { - // append the body text - const QString previousBody = container->data()[QStringLiteral("body")].toString(); - if (previousBody != bodyFinal) { - // FIXME: This will just append the entire old XML document to another one, leading to: - // old
new - // It works but is not very clean. - bodyFinal = previousBody + QStringLiteral("
") + bodyFinal; - } - - replaces_id = partOf; - - // remove the old notification and replace it with the new one - // TODO: maybe just update the current notification? - CloseNotification(partOf); - } - } else if (bodyFinal.isEmpty()) { + if (bodyFinal.isEmpty()) { //some ridiculous apps will send just a title (#372112), in that case, treat it as though there's only a body bodyFinal = summary; summaryFinal = app_name; } - uint id = replaces_id ? replaces_id : m_nextId++; - - // If the current app is in the "blacklist"... - if (m_alwaysReplaceAppsList.contains(app_name)) { - // ...check if we already have a notification from that particular - // app and if yes, use its id to replace it - if (m_notificationsFromReplaceableApp.contains(app_name)) { - id = m_notificationsFromReplaceableApp.value(app_name); - } else { - m_notificationsFromReplaceableApp.insert(app_name, id); - } - } + uint id = notification.id();// replaces_id ? replaces_id : m_nextId++; QString appname_str = app_name; if (appname_str.isEmpty()) { appname_str = i18n("Unknown Application"); } - bool isPersistent = timeout == 0; + bool isPersistent = (timeout == 0); const int AVERAGE_WORD_LENGTH = 6; const int WORD_PER_MINUTE = 250; - int count = summary.length() + body.length() - strlen(""); + int count = notification.summary().length() + notification.body().length() - strlen(""); // -1 is "server default", 0 is persistent with "server default" display time, // anything more should honor the setting @@ -285,10 +123,25 @@ Plasma::DataEngine::Data notificationData; notificationData.insert(QStringLiteral("id"), QString::number(id)); notificationData.insert(QStringLiteral("eventId"), eventId); - notificationData.insert(QStringLiteral("appName"), appname_str); - notificationData.insert(QStringLiteral("appIcon"), app_icon); + notificationData.insert(QStringLiteral("appName"), notification.applicationName()); + // TODO should be proper passed in icon? + notificationData.insert(QStringLiteral("appIcon"), notification.applicationIconName()); notificationData.insert(QStringLiteral("summary"), summaryFinal); notificationData.insert(QStringLiteral("body"), bodyFinal); + + QStringList actions; + for (int i = 0; i < notification.actionNames().count(); ++i) { + actions << notification.actionNames().at(i) << notification.actionLabels().at(i); + } + // NotificationManager hides the configure and default stuff from us but we need to re-add them + // to the actions list for compatibility + if (!notification.configureActionLabel().isEmpty()) { + actions << QStringLiteral("settings") << notification.configureActionLabel(); + } + if (notification.hasDefaultAction()) { + actions << QStringLiteral("default") << QString(); + } + notificationData.insert(QStringLiteral("actions"), actions); notificationData.insert(QStringLiteral("isPersistent"), isPersistent); notificationData.insert(QStringLiteral("expireTimeout"), timeout); @@ -301,70 +154,36 @@ notificationData.insert(QStringLiteral("appServiceIcon"), service->icon()); } - bool configurable = false; - if (!appRealName.isEmpty()) { + notificationData.insert(QStringLiteral("appRealName"), appRealName); + // NotificationManager configurable is anything that has a notifyrc or desktop entry + // but the old stuff assumes only stuff with notifyrc to be configurable + notificationData.insert(QStringLiteral("configurable"), !notification.notifyRcName().isEmpty()); - if (m_configurableApplications.contains(appRealName)) { - configurable = m_configurableApplications.value(appRealName); - } else { - // Check whether the application actually has notifications we can configure - KConfig config(appRealName + QStringLiteral(".notifyrc"), KConfig::NoGlobals); - config.addConfigSources(QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, - QStringLiteral("knotifications5/") + appRealName + QStringLiteral(".notifyrc"))); + QImage image = notification.image(); + notificationData.insert(QStringLiteral("image"), image.isNull() ? QVariant() : image); - const QRegularExpression regexp(QStringLiteral("^Event/([^/]*)$")); - configurable = !config.groupList().filter(regexp).isEmpty(); - m_configurableApplications.insert(appRealName, configurable); - } + int urgency = -1; + switch (notification.urgency()) { + case Notifications::LowUrgency: + urgency = 0; + break; + case Notifications::NormalUrgency: + urgency = 1; + break; + case Notifications::CriticalUrgency: + urgency = 2; + break; } - notificationData.insert(QStringLiteral("appRealName"), appRealName); - notificationData.insert(QStringLiteral("configurable"), configurable); - - QImage image; - // Underscored hints was in use in version 1.1 of the spec but has been - // replaced by dashed hints in version 1.2. We need to support it for - // users of the 1.2 version of the spec. - if (hints.contains(QStringLiteral("image-data"))) { - QDBusArgument arg = hints[QStringLiteral("image-data")].value(); - image = decodeNotificationSpecImageHint(arg); - } else if (hints.contains(QStringLiteral("image_data"))) { - QDBusArgument arg = hints[QStringLiteral("image_data")].value(); - image = decodeNotificationSpecImageHint(arg); - } else if (hints.contains(QStringLiteral("image-path"))) { - QString path = findImageForSpecImagePath(hints[QStringLiteral("image-path")].toString()); - if (!path.isEmpty()) { - image.load(path); - } - } else if (hints.contains(QStringLiteral("image_path"))) { - QString path = findImageForSpecImagePath(hints[QStringLiteral("image_path")].toString()); - if (!path.isEmpty()) { - image.load(path); - } - } else if (hints.contains(QStringLiteral("icon_data"))) { - // This hint was in use in version 1.0 of the spec but has been - // replaced by "image_data" in version 1.1. We need to support it for - // users of the 1.0 version of the spec. - QDBusArgument arg = hints[QStringLiteral("icon_data")].value(); - image = decodeNotificationSpecImageHint(arg); - } - notificationData.insert(QStringLiteral("image"), image.isNull() ? QVariant() : image); - if (hints.contains(QStringLiteral("urgency"))) { - notificationData.insert(QStringLiteral("urgency"), hints[QStringLiteral("urgency")].toInt()); + if (urgency > -1) { + notificationData.insert(QStringLiteral("urgency"), urgency); } notificationData.insert(QStringLiteral("urls"), urls); setData(source, notificationData); - m_activeNotifications.insert(source, app_name + summary); - - return id; -} - -void NotificationsEngine::CloseNotification(uint id) -{ - removeNotification(id, 3); + m_activeNotifications.insert(source, notification.applicationName() + notification.summary()); } void NotificationsEngine::removeNotification(uint id, uint closeReason) @@ -374,41 +193,28 @@ // it has already been closed so don't notify a second time if (m_activeNotifications.remove(source) > 0) { removeSource(source); - emit NotificationClosed(id, closeReason); + Server::self().closeNotification(id, static_cast(closeReason)); } } Plasma::Service* NotificationsEngine::serviceForSource(const QString& source) { return new NotificationService(this, source); } -QStringList NotificationsEngine::GetCapabilities() -{ - return QStringList() - << QStringLiteral("body") - << QStringLiteral("body-hyperlinks") - << QStringLiteral("body-markup") - << QStringLiteral("body-images") - << QStringLiteral("icon-static") - << QStringLiteral("actions") - ; -} - -// FIXME: Signature is ugly -QString NotificationsEngine::GetServerInformation(QString& vendor, QString& version, QString& specVersion) -{ - vendor = QLatin1String("KDE"); - version = QLatin1String("2.0"); // FIXME - specVersion = QLatin1String("1.1"); - return QStringLiteral("Plasma"); -} - int NotificationsEngine::createNotification(const QString &appName, const QString &appIcon, const QString &summary, const QString &body, int timeout, const QStringList &actions, const QVariantMap &hints) { - Notify(appName, 0, appIcon, summary, body, actions, hints, timeout); - return m_nextId; + Notification notification; + notification.setApplicationName(appName); + notification.setApplicationIconName(appIcon); + notification.setSummary(summary); + notification.setBody(body); // sanitizes + notification.setActions(actions); + notification.setTimeout(timeout); + notification.processHints(hints); + Server::self().add(notification); + return 0; } void NotificationsEngine::configureNotification(const QString &appName, const QString &eventId) @@ -435,45 +241,6 @@ return rc; } -void NotificationsEngine::onBroadcastNotification(const QMap &properties) -{ - qCDebug(NOTIFICATIONS) << "Received broadcast notification"; - - const auto currentUserId = KUserId::currentEffectiveUserId().nativeId(); - - // a QVariantList with ints arrives as QDBusArgument here, using a QStringList for simplicity - const QStringList &userIds = properties.value(QStringLiteral("uids")).toStringList(); - if (!userIds.isEmpty()) { - auto it = std::find_if(userIds.constBegin(), userIds.constEnd(), [currentUserId](const QVariant &id) { - bool ok; - auto uid = id.toString().toLongLong(&ok); - return ok && uid == currentUserId; - }); - - if (it == userIds.constEnd()) { - qCDebug(NOTIFICATIONS) << "It is not meant for us, ignoring"; - return; - } - } - - bool ok; - int timeout = properties.value(QStringLiteral("timeout")).toInt(&ok); - if (!ok) { - timeout = -1; // -1 = server default, 0 would be "persistent" - } - - Notify( - properties.value(QStringLiteral("appName")).toString(), - 0, // replaces_id - properties.value(QStringLiteral("appIcon")).toString(), - properties.value(QStringLiteral("summary")).toString(), - properties.value(QStringLiteral("body")).toString(), - {}, // no actions - properties.value(QStringLiteral("hints")).toMap(), - timeout - ); -} - K_EXPORT_PLASMA_DATAENGINE_WITH_JSON(notifications, NotificationsEngine, "plasma-dataengine-notifications.json") #include "notificationsengine.moc" diff --git a/libnotificationmanager/CMakeLists.txt b/libnotificationmanager/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/libnotificationmanager/CMakeLists.txt @@ -0,0 +1,108 @@ +add_subdirectory(declarative) +if(BUILD_TESTING) + #add_subdirectory(autotests) +endif() + +set(notificationmanager_LIB_SRCS + server.cpp + server_p.cpp + settings.cpp + notifications.cpp + notification.cpp + + notificationsmodel.cpp + notificationfilterproxymodel.cpp + notificationsortproxymodel.cpp + notificationgroupingproxymodel.cpp + notificationgroupcollapsingproxymodel.cpp + + jobsmodel.cpp + job.cpp + jobdetails.cpp + + limitedrowcountproxymodel.cpp +) + +set(HAVE_PULSEAUDIOQT ${KF5PulseAudioQt_FOUND}) +configure_file(config-notificationmanager.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-notificationmanager.h ) + +ecm_qt_declare_logging_category(notificationmanager_LIB_SRCS + HEADER debug.h + IDENTIFIER NOTIFICATIONMANAGER + CATEGORY_NAME org.kde.plasma.notifications) +install(FILES libnotificationmanager.categories DESTINATION ${KDE_INSTALL_CONFDIR}) + +# Settings +kconfig_add_kcfg_files(notificationmanager_LIB_SRCS kcfg/donotdisturbsettings.kcfgc) +kconfig_add_kcfg_files(notificationmanager_LIB_SRCS kcfg/notificationsettings.kcfgc) +kconfig_add_kcfg_files(notificationmanager_LIB_SRCS kcfg/jobsettings.kcfgc) +kconfig_add_kcfg_files(notificationmanager_LIB_SRCS kcfg/badgesettings.kcfgc) + +# DBus +qt5_add_dbus_adaptor(notificationmanager_LIB_SRCS dbus/org.freedesktop.Notifications.xml server_p.h NotificationManager::ServerPrivate) + +add_library(notificationmanager ${notificationmanager_LIB_SRCS}) +add_library(PW::LibNotificationManager ALIAS notificationmanager) + +target_compile_definitions(notificationmanager PRIVATE -DPROJECT_VERSION="${PROJECT_VERSION}") + +generate_export_header(notificationmanager) + +target_include_directories(notificationmanager PUBLIC "$" "$") + +target_link_libraries(notificationmanager + PUBLIC + Qt5::Core + Qt5::Gui + Qt5::Quick + KF5::ConfigCore + KF5::ItemModels + PRIVATE + Qt5::DBus + KF5::ConfigGui + KF5::I18n + KF5::IconThemes + KF5::KIOFileWidgets + KF5::Plasma + KF5::ProcessCore + KF5::Service +) + +if(KF5PulseAudioQt_FOUND) + target_link_libraries(notificationmanager PRIVATE KF5::PulseAudioQt) +endif() + +set_target_properties(notificationmanager PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION 1 + EXPORT_NAME LibNotificationManager) + +install(TARGETS notificationmanager EXPORT notificationmanagerLibraryTargets ${KDE_INSTALL_TARGETS_DEFAULT_ARGS} ) + +install(FILES + server.h + notification.h + settings.h + ${CMAKE_CURRENT_BINARY_DIR}/notificationmanager_export.h + DESTINATION ${KDE_INSTALL_INCLUDEDIR}/notificationmanager COMPONENT Devel +) + +write_basic_config_version_file(${CMAKE_CURRENT_BINARY_DIR}/LibNotificationManagerConfigVersion.cmake VERSION "${PROJECT_VERSION}" COMPATIBILITY AnyNewerVersion) + +set(CMAKECONFIG_INSTALL_DIR ${KDE_INSTALL_LIBDIR}/cmake/LibNotificationManager) + +configure_package_config_file(LibNotificationManagerConfig.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/LibNotificationManagerConfig.cmake" + INSTALL_DESTINATION ${CMAKECONFIG_INSTALL_DIR}) + +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/LibNotificationManagerConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/LibNotificationManagerConfigVersion.cmake + DESTINATION ${CMAKECONFIG_INSTALL_DIR}) + +install(EXPORT notificationmanagerLibraryTargets + NAMESPACE PW:: + DESTINATION ${CMAKECONFIG_INSTALL_DIR} + FILE LibNotificationManagerLibraryTargets.cmake ) + +install(FILES plasmanotifyrc + DESTINATION ${KDE_INSTALL_CONFDIR}) diff --git a/libnotificationmanager/LibNotificationManagerConfig.cmake.in b/libnotificationmanager/LibNotificationManagerConfig.cmake.in new file mode 100644 --- /dev/null +++ b/libnotificationmanager/LibNotificationManagerConfig.cmake.in @@ -0,0 +1,9 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(Qt5Core "@QT_MIN_VERSION@") +find_dependency(Qt5Gui "@QT_MIN_VERSION@") +find_dependency(Qt5Quick "@QT_MIN_VERSION@") +find_dependency(KF5ItemModels "@KF5_MIN_VERSION@") + +include("${CMAKE_CURRENT_LIST_DIR}/LibNotificationManagerLibraryTargets.cmake") diff --git a/libnotificationmanager/config-notificationmanager.h.cmake b/libnotificationmanager/config-notificationmanager.h.cmake new file mode 100644 --- /dev/null +++ b/libnotificationmanager/config-notificationmanager.h.cmake @@ -0,0 +1 @@ +#cmakedefine HAVE_PULSEAUDIOQT diff --git a/dataengines/notifications/org.freedesktop.Notifications.xml b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml rename from dataengines/notifications/org.freedesktop.Notifications.xml rename to libnotificationmanager/dbus/org.freedesktop.Notifications.xml --- a/dataengines/notifications/org.freedesktop.Notifications.xml +++ b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml @@ -33,5 +33,28 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/libnotificationmanager/declarative/CMakeLists.txt b/libnotificationmanager/declarative/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/libnotificationmanager/declarative/CMakeLists.txt @@ -0,0 +1,9 @@ +include_directories(${CMAKE_CURRENT_BINARY_DIR}/.. ${CMAKE_CURRENT_SOURCE_DIR}/..) + +add_library(notificationmanagerplugin SHARED notificationmanagerplugin.cpp) + +target_link_libraries(notificationmanagerplugin Qt5::Qml notificationmanager) + +install(TARGETS notificationmanagerplugin DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/notificationmanager) +install(FILES qmldir DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/notificationmanager) + diff --git a/libnotificationmanager/declarative/notificationmanagerplugin.h b/libnotificationmanager/declarative/notificationmanagerplugin.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/declarative/notificationmanagerplugin.h @@ -0,0 +1,38 @@ +/* + * Copyright 2018 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include + +namespace NotificationManager +{ + +class NotificationManagerPlugin : public QQmlExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QQmlExtensionInterface") + +public: + void registerTypes(const char *uri) override; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/declarative/notificationmanagerplugin.cpp b/libnotificationmanager/declarative/notificationmanagerplugin.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/declarative/notificationmanagerplugin.cpp @@ -0,0 +1,38 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "notificationmanagerplugin.h" + +#include "notifications.h" +#include "jobdetails.h" +#include "settings.h" + +#include + +using namespace NotificationManager; + +void NotificationManagerPlugin::registerTypes(const char *uri) +{ + Q_ASSERT(uri == QLatin1String("org.kde.notificationmanager")); + + qmlRegisterType(uri, 1, 0, "Notifications"); + qmlRegisterType(); + qmlRegisterType(uri, 1, 0, "Settings"); +} diff --git a/libnotificationmanager/declarative/qmldir b/libnotificationmanager/declarative/qmldir new file mode 100644 --- /dev/null +++ b/libnotificationmanager/declarative/qmldir @@ -0,0 +1,2 @@ +module org.kde.notificationmanager +plugin notificationmanagerplugin diff --git a/libnotificationmanager/job.h b/libnotificationmanager/job.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/job.h @@ -0,0 +1,107 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include + +//#include "notificationmanager_export.h" + +#include "notifications.h" + + +namespace NotificationManager +{ + +class JobDetails; + +class Job +{ +public: + explicit Job(const QString &sourceName = QString()); + ~Job(); + + QString sourceName() const; + + QDateTime created() const; + + QDateTime updated() const; + void setUpdated(); + + QString summary() const; + + QString desktopEntry() const; + QString applicationName() const; + QString applicationIconName() const; + + Notifications::JobState state() const; + int percentage() const; + int error() const; + QString errorText() const; + bool suspendable() const; + bool killable() const; + + JobDetails *details() const; + + bool expired() const; + void setExpired(bool expired); + + bool dismissed() const; + void setDismissed(bool dismissed); + + QVector processData(const QVariantMap/*Plasma::DataEngine::Data*/ &data); + + bool operator==(const Job &other) const; + +private: + friend class JobsModel; + + QString m_sourceName; + + QDateTime m_created; + QDateTime m_updated; + + QString m_summary; + + // raw appName and appIconName from kuiserver + QString m_appName; + QString m_appIconName; + // names looked up from a service + QString m_desktopEntry; + QString m_applicationName; + QString m_applicationIconName; + + Notifications::JobState m_state = Notifications::JobStateStopped; + int m_percentage = 0; + int m_error = 0; + QString m_errorText; + bool m_suspendable = false; + bool m_killable = false; + + JobDetails *m_details = nullptr; + + bool m_expired = false; + bool m_dismissed = false; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/job.cpp b/libnotificationmanager/job.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/job.cpp @@ -0,0 +1,244 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "job.h" + +#include + +#include + +#include "notifications.h" + +#include "jobdetails.h" +#include "jobdetails_p.h" + +#include + +using namespace NotificationManager; + +Job::Job(const QString &sourceName) + : m_sourceName(sourceName) + , m_created(QDateTime::currentDateTimeUtc()) + , m_details(new JobDetails()) +{ + // FIXME still needed? + QQmlEngine::setObjectOwnership(m_details, QQmlEngine::CppOwnership); +} + +Job::~Job() +{ + delete m_details; +} + +QString Job::sourceName() const +{ + return m_sourceName; +} + +QDateTime Job::created() const +{ + return m_created; +} + +QDateTime Job::updated() const +{ + return m_updated; +} + +void Job::setUpdated() +{ + m_updated = QDateTime::currentDateTimeUtc(); +} + +QString Job::summary() const +{ + return m_summary; +} + +QString Job::desktopEntry() const +{ + return m_desktopEntry; +} + +QString Job::applicationName() const +{ + return m_applicationName; +} + +QString Job::applicationIconName() const +{ + return m_applicationIconName; +} + +Notifications::JobState Job::state() const +{ + return m_state; +} + +int Job::percentage() const +{ + return m_percentage; +} + +int Job::error() const +{ + return m_error; +} + +QString Job::errorText() const +{ + return m_errorText; +} + +bool Job::suspendable() const +{ + return m_suspendable; +} + +bool Job::killable() const +{ + return m_killable; +} + +JobDetails *Job::details() const +{ + return m_details; +} + +bool Job::expired() const +{ + return m_expired; +} + +void Job::setExpired(bool expired) +{ + m_expired = expired; +} + +bool Job::dismissed() const +{ + return m_dismissed; +} + +void Job::setDismissed(bool dismissed) +{ + m_dismissed = dismissed; +} + +template void processField(const QVariantMap/*Plasma::DataEngine::Data*/ &data, + const QString &field, + T &target, + int role, + QVector &dirtyRoles) +{ + auto it = data.find(field); + if (it != data.end()) { + const T newValue = it->value(); + if (target != newValue) { + target = newValue; + dirtyRoles.append(role); + } + } +} + +QVector Job::processData(const QVariantMap/*Plasma::DataEngine::Data*/ &data) +{ + QVector dirtyRoles; + + auto end = data.end(); + + processField(data, QStringLiteral("infoMessage"), m_summary, Notifications::SummaryRole, dirtyRoles); + processField(data, QStringLiteral("percentage"), m_percentage, Notifications::PercentageRole, dirtyRoles); + processField(data, QStringLiteral("errorText"), m_errorText, Notifications::ErrorTextRole, dirtyRoles); + processField(data, QStringLiteral("error"), m_error, Notifications::ErrorRole, dirtyRoles); + + /*if (m_errorText.isEmpty() && m_error) { + m_errorText = KIO::buildErrorString(m_error); + dirtyRoles.append(Notifications::ErrorTextRole); + }*/ + + processField(data, QStringLiteral("killable"), m_killable, Notifications::KillableRole, dirtyRoles); + processField(data, QStringLiteral("suspendable"), m_suspendable, Notifications::SuspendableRole, dirtyRoles); + + auto it = data.find("appName"); + if (it != end) { + const QString appName = it->toString(); + if (m_appName != appName) { + m_appName = appName; + + QString applicationName = appName; + QString applicationIconName = data.value(QStringLiteral("appIconName")).toString(); + + KService::Ptr service = KService::serviceByStorageId(appName); + if (!service) { + // HACK would be nice to add a JobViewV3 which works on desktop entries instead of app names + // cf. KUiServerJobTracker::registerJob in kjobwidgets + service = KService::serviceByStorageId(QStringLiteral("org.kde.") + appName); + } + + if (service) { + if (m_desktopEntry != service->desktopEntryName()) { + m_desktopEntry = service->desktopEntryName(); + dirtyRoles.append(Notifications::DesktopEntryRole); + } + + applicationName = service->name(); + applicationIconName = service->icon(); + } + + if (m_applicationName != applicationName) { + m_applicationName = applicationName; + dirtyRoles.append(Notifications::ApplicationNameRole); + } + + if (m_applicationIconName != applicationIconName) { + m_applicationIconName = applicationIconName; + dirtyRoles.append(Notifications::ApplicationIconNameRole); + } + } + } + + it = data.find(QStringLiteral("state")); + if (it != end) { + const QString stateString = it->toString(); + + Notifications::JobState state = Notifications::JobStateRunning; + + if (stateString == QLatin1String("suspended")) { + state = Notifications::JobStateSuspended; + } else if (stateString == QLatin1String("stopped")) { + state = Notifications::JobStateStopped; + } + + if (m_state != state) { + m_state = state; + dirtyRoles.append(Notifications::JobStateRole); + } + } + + m_details->d->processData(data); + + return dirtyRoles; +} + +bool Job::operator==(const Job &other) const +{ + return other.m_sourceName == m_sourceName; +} diff --git a/libnotificationmanager/jobdetails.h b/libnotificationmanager/jobdetails.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobdetails.h @@ -0,0 +1,135 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "notificationmanager_export.h" + +#include "notifications.h" + +namespace NotificationManager +{ + +/** + * @short Provides detailed information about a job + * + * @author Kai Uwe Broulik + **/ +class NOTIFICATIONMANAGER_EXPORT JobDetails : public QObject +{ + Q_OBJECT + + /** + * User-friendly compact description text of the job, + * for example "42 of 1337 files to "~/some/folder", or + * "SomeFile.txt to Downloads". + */ + Q_PROPERTY(QString text READ text NOTIFY textChanged) + + /** + * The destination URL of a job. + */ + Q_PROPERTY(QUrl destUrl READ destUrl NOTIFY destUrlChanged) + + /** + * Current transfer rate in Byte/s + */ + Q_PROPERTY(qulonglong speed READ speed NOTIFY speedChanged) + + Q_PROPERTY(qulonglong processedBytes READ processedBytes NOTIFY processedBytesChanged) + Q_PROPERTY(qulonglong processedFiles READ processedFiles NOTIFY processedFilesChanged) + Q_PROPERTY(qulonglong processedDirectories READ processedDirectories NOTIFY processedDirectoriesChanged) + + Q_PROPERTY(qulonglong totalBytes READ totalBytes NOTIFY totalBytesChanged) + Q_PROPERTY(qulonglong totalFiles READ totalFiles NOTIFY totalFilesChanged) + Q_PROPERTY(qulonglong totalDirectories READ totalDirectories NOTIFY totalDirectoriesChanged) + + Q_PROPERTY(QString descriptionLabel1 READ descriptionLabel1 NOTIFY descriptionLabel1Changed) + Q_PROPERTY(QString descriptionValue1 READ descriptionValue1 NOTIFY descriptionValue1Changed) + + Q_PROPERTY(QString descriptionLabel2 READ descriptionLabel2 NOTIFY descriptionLabel2Changed) + Q_PROPERTY(QString descriptionValue2 READ descriptionValue2 NOTIFY descriptionValue2Changed) + + /** + * This tries to generate a valid URL for a file that's being processed by converting descriptionValue2 + * (which is assumed to be the Destination) and if that isn't valid, descriptionValue1 to a URL. + */ + Q_PROPERTY(QUrl descriptionUrl READ descriptionUrl NOTIFY descriptionUrlChanged) + +public: + explicit JobDetails(QObject *parent = nullptr); + ~JobDetails() override; + + QString text() const; + + QUrl destUrl() const; + + qulonglong speed() const; + + qulonglong processedBytes() const; + qulonglong processedFiles() const; + qulonglong processedDirectories() const; + + qulonglong totalBytes() const; + qulonglong totalFiles() const; + qulonglong totalDirectories() const; + + QString descriptionLabel1() const; + QString descriptionValue1() const; + + QString descriptionLabel2() const; + QString descriptionValue2() const; + + QUrl descriptionUrl() const; + +signals: + void textChanged(); + void destUrlChanged(); + void speedChanged(); + void processedBytesChanged(); + void processedFilesChanged(); + void processedDirectoriesChanged(); + void processedAmountChanged(); + void totalBytesChanged(); + void totalFilesChanged(); + void totalDirectoriesChanged(); + void totalAmountChanged(); + void descriptionLabel1Changed(); + void descriptionValue1Changed(); + void descriptionLabel2Changed(); + void descriptionValue2Changed(); + void descriptionUrlChanged(); + +private: + friend class Job; + + class Private; + QScopedPointer d; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/jobdetails.cpp b/libnotificationmanager/jobdetails.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobdetails.cpp @@ -0,0 +1,328 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "jobdetails.h" + +#include +#include + +#include +#include + +#include "jobdetails_p.h" + +#include "debug.h" + +using namespace NotificationManager; + +JobDetails::Private::Private(JobDetails *q) + : q(q) + , m_placesModel(createPlacesModel()) +{ + +} + +JobDetails::Private::~Private() = default; + +QSharedPointer JobDetails::Private::createPlacesModel() +{ + static QWeakPointer s_instance; + if (!s_instance) { + QSharedPointer ptr(new KFilePlacesModel()); + s_instance = ptr.toWeakRef(); + return ptr; + } + return s_instance.toStrongRef(); +} + +// Tries to return a more user-friendly displayed destination +// - if it is a place, show the name, e.g. "Downloads" +// - if it is inside home, abbreviate that to tilde ~/foo +// - otherwise print URL (without password) +QString JobDetails::Private::prettyDestUrl() const +{ + QUrl url = m_destUrl; + // In case of a single file and no destUrl, try using the second label (most likely "Destination")... + if (!url.isValid() && m_totalFiles == 1) { + url = QUrl::fromUserInput(m_descriptionValue2, QString(), QUrl::AssumeLocalFile).adjusted(QUrl::RemoveFilename); + } + + if (!url.isValid()) { + return QString(); + } + + if (!m_placesModel) { + m_placesModel = createPlacesModel(); + } + + // If we copy into a "place", show its pretty name instead of a URL/path + for (int row = 0; row < m_placesModel->rowCount(); ++row) { + const QModelIndex idx = m_placesModel->index(row, 0); + if (m_placesModel->isHidden(idx)) { + continue; + } + + if (m_placesModel->url(idx).matches(url, QUrl::StripTrailingSlash)) { + return m_placesModel->text(idx); + } + } + + if (url.isLocalFile()) { + QString destUrlString = url.toLocalFile(); + + const QString homePath = QDir::homePath(); + if (destUrlString.startsWith(homePath)) { + destUrlString = QStringLiteral("~") + destUrlString.mid(homePath.length()); + } + + return destUrlString; + } + + return url.toDisplayString(); // strips password +} + +QString JobDetails::Private::text() const +{ + const QString currentFileName = descriptionUrl().fileName(); + const QString destUrlString = prettyDestUrl(); + + if (m_totalFiles == 0) { + if (!destUrlString.isEmpty()) { + if (m_processedFiles > 0) { + return i18ncp("Copying n files to location", "%1 file to %2", "%1 files to %2", + m_processedFiles, destUrlString); + } + return i18nc("Copying unknown amount of files to location", "to %1", destUrlString); + } else if (m_processedFiles > 0) { + return i18ncp("Copying n files", "%1 file", "%1 files", m_processedFiles); + } + } else if (m_totalFiles == 1 && !currentFileName.isEmpty()) { + if (!destUrlString.isEmpty()) { + return i18nc("Copying file to location", "%1 to %2", currentFileName, destUrlString); + } + + return currentFileName; + } else if (m_totalFiles > 1) { + if (!destUrlString.isEmpty()) { + if (m_processedFiles > 0 && m_processedFiles <= m_totalFiles) { + return i18ncp("Copying n of m files to locaton", "%2 of %1 file to %3", "%2 of %1 files to %3", + m_totalFiles, m_processedFiles, destUrlString); + } + return i18ncp("Copying n files to location", "%1 file to %2", "%1 files to %2", + m_processedFiles > 0 ? m_processedFiles : m_totalFiles, destUrlString); + } + + if (m_processedFiles > 0 && m_processedFiles <= m_totalFiles) { + return i18ncp("Copying n of m files", "%2 of %1 file", "%2 of %1 files", + m_totalFiles, m_processedFiles); + } + + return i18ncp("Copying n files", "%1 file", "%1 files", m_processedFiles > 0 ? m_processedFiles : m_totalFiles); + } + + qCInfo(NOTIFICATIONMANAGER) << "Failed to generate job text for job with following properties:"; + qCInfo(NOTIFICATIONMANAGER) << " processedFiles =" << m_processedFiles << ", totalFiles =" << m_totalFiles + << ", current file name =" << currentFileName << ", destination url string =" << destUrlString; + qCInfo(NOTIFICATIONMANAGER) << "label1 =" << m_descriptionLabel1 << ", value1 =" << m_descriptionValue1 + << ", label2 =" << m_descriptionLabel2 << ", value2 =" << m_descriptionValue2; + + return QString(); +} + +template bool processField(const QVariantMap/*Plasma::DataEngine::Data*/ &data, + const QString &field, + T &target, + JobDetails *details, + void (JobDetails::*changeSignal)()) +{ + auto it = data.find(field); + if (it != data.end()) { + const T newValue = it->value(); + if (target != newValue) { + target = newValue; + emit ((details)->*changeSignal)(); + return true; + } + } + return false; +} + + +void JobDetails::Private::processData(const QVariantMap &data) +{ + bool textDirty = false; + bool urlDirty = false; + + auto it = data.find(QStringLiteral("destUrl")); + if (it != data.end()) { + const QUrl destUrl = it->toUrl(); + if (m_destUrl != destUrl) { + m_destUrl = destUrl; + textDirty = true; + emit q->destUrlChanged(); + } + } + + processField(data, QStringLiteral("labelName0"), m_descriptionLabel1, + q, &JobDetails::descriptionLabel1Changed); + if (processField(data, QStringLiteral("label0"), m_descriptionValue1, + q, &JobDetails::descriptionValue1Changed)) { + textDirty = true; + urlDirty = true; + } + + processField(data, QStringLiteral("labelName1"), m_descriptionLabel2, + q, &JobDetails::descriptionLabel2Changed); + if (processField(data, QStringLiteral("label1"), m_descriptionValue2, + q, &JobDetails::descriptionValue2Changed)) { + textDirty = true; + urlDirty = true; + } + + processField(data, QStringLiteral("numericSpeed"), m_speed, + q, &JobDetails::speedChanged); + + for (int i = 0; i <= 2; ++i) { + { + const QString unit = data.value(QStringLiteral("processedUnit%1").arg(QString::number(i))).toString(); + const QString amountKey = QStringLiteral("processedAmount%1").arg(QString::number(i)); + + if (unit == QLatin1String("bytes")){ + processField(data, amountKey, m_processedBytes, q, &JobDetails::processedBytesChanged); + } else if (unit == QLatin1String("files")) { + if (processField(data, amountKey, m_processedFiles, q, &JobDetails::processedFilesChanged)) { + textDirty = true; + } + } else if (unit == QLatin1String("dirs")) { + processField(data, amountKey, m_processedDirectories, q, &JobDetails::processedDirectoriesChanged); + } + } + + { + const QString unit = data.value(QStringLiteral("totalUnit%1").arg(QString::number(i))).toString(); + const QString amountKey = QStringLiteral("totalAmount%1").arg(QString::number(i)); + + if (unit == QLatin1String("bytes")){ + processField(data, amountKey, m_totalBytes, q, &JobDetails::totalBytesChanged); + } else if (unit == QLatin1String("files")) { + if (processField(data, amountKey, m_totalFiles, q, &JobDetails::totalFilesChanged)) { + textDirty = true; + } + } else if (unit == QLatin1String("dirs")) { + processField(data, amountKey, m_totalDirectories, q, &JobDetails::totalDirectoriesChanged); + } + } + } + + if (urlDirty) { + emit q->descriptionUrlChanged(); + } + if (urlDirty || textDirty) { + emit q->textChanged(); + } +} + +QUrl JobDetails::Private::descriptionUrl() const +{ + QUrl url = QUrl::fromUserInput(m_descriptionValue2, QString(), QUrl::AssumeLocalFile); + if (!url.isValid()) { + url = QUrl::fromUserInput(m_descriptionValue1, QString(), QUrl::AssumeLocalFile); + } + return url; +} + +JobDetails::JobDetails(QObject *parent) + : QObject(parent) + , d(new Private(this)) +{ + +} + +JobDetails::~JobDetails() = default; + +QString JobDetails::text() const +{ + return d->text(); +} + +QUrl JobDetails::destUrl() const +{ + return d->m_destUrl; +} + +qulonglong JobDetails::speed() const +{ + return d->m_speed; +} + +qulonglong JobDetails::processedBytes() const +{ + return d->m_processedBytes; +} + +qulonglong JobDetails::processedFiles() const +{ + return d->m_processedFiles; +} + +qulonglong JobDetails::processedDirectories() const +{ + return d->m_processedDirectories; +} + +qulonglong JobDetails::totalBytes() const +{ + return d->m_totalBytes; +} + +qulonglong JobDetails::totalFiles() const +{ + return d->m_totalFiles; +} + +qulonglong JobDetails::totalDirectories() const +{ + return d->m_totalDirectories; +} + +QString JobDetails::descriptionLabel1() const +{ + return d->m_descriptionLabel1; +} + +QString JobDetails::descriptionValue1() const +{ + return d->m_descriptionValue1; +} + +QString JobDetails::descriptionLabel2() const +{ + return d->m_descriptionLabel2; +} + +QString JobDetails::descriptionValue2() const +{ + return d->m_descriptionValue2; +} + +QUrl JobDetails::descriptionUrl() const +{ + return d->descriptionUrl(); +} diff --git a/libnotificationmanager/jobdetails_p.h b/libnotificationmanager/jobdetails_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobdetails_p.h @@ -0,0 +1,77 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include "jobdetails.h" + +class KFilePlacesModel; + +namespace NotificationManager +{ + +class JobDetails; + +class JobDetails::Private +{ +public: + Private(JobDetails *q); + ~Private(); + + QString prettyDestUrl() const; + QString text() const; + QUrl descriptionUrl() const; + + void processData(const QVariantMap /*Plasma::DataEngine::Data*/ &data); + +public: + QUrl m_destUrl; + + qulonglong m_speed = 0; + + qulonglong m_processedBytes = 0; + qulonglong m_processedFiles = 0; + qulonglong m_processedDirectories = 0; + + qulonglong m_totalBytes = 0; + qulonglong m_totalFiles = 0; + qulonglong m_totalDirectories = 0; + + QString m_descriptionLabel1; + QString m_descriptionValue1; + + QString m_descriptionLabel2; + QString m_descriptionValue2; + +private: + static QSharedPointer createPlacesModel(); + + JobDetails *q; + + mutable QSharedPointer m_placesModel; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/jobsmodel.h b/libnotificationmanager/jobsmodel.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobsmodel.h @@ -0,0 +1,71 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include + +#include "notifications.h" + +namespace NotificationManager +{ + +class Notification; + +class JobsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + ~JobsModel() override; + + using Ptr = QSharedPointer; + static Ptr createJobsModel(); + + QVariant data(const QModelIndex &index, int role) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role) override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + void close(const QString &jobId); + void expire(const QString &jobId); + + void suspend(const QString &jobId); + void resume(const QString &jobId); + void kill(const QString &jobId); + + void clear(Notifications::ClearFlags flags); + +private slots: + void dataUpdated(const QString &sourceName, const Plasma::DataEngine::Data &data); + +private: + class Private; + QScopedPointer d; + + JobsModel(); + Q_DISABLE_COPY(JobsModel) + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/jobsmodel.cpp b/libnotificationmanager/jobsmodel.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobsmodel.cpp @@ -0,0 +1,335 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "jobsmodel.h" + +#include "notifications.h" + +#include +#include + +#include + +#include +#include +#include + +#include "job.h" +#include "jobdetails.h" + +using namespace NotificationManager; + +class Q_DECL_HIDDEN JobsModel::Private +{ +public: + explicit Private(JobsModel *q); + ~Private(); + + void onSourceAdded(const QString &source); + void onSourceRemoved(const QString &source); + void operationCall(const QString &source, const QString &operation); + + JobsModel *q; + + QScopedPointer dataEngineConsumer; + Plasma::DataEngine *dataEngine; + + QStringList sources; + // FIXME why is this a pointer? + // Try making it not anymore and make sure jobdetails has + // a copy constructor that copies its private stuff + QHash jobs; + +}; + +JobsModel::Private::Private(JobsModel *q) + : q(q) + , dataEngineConsumer(new Plasma::DataEngineConsumer) + , dataEngine(dataEngineConsumer->dataEngine(QStringLiteral("applicationjobs"))) +{ + + // NOTE cannot call onSourceAdded from constructor directly + // because it does beginInsertRow and then blows up + QMetaObject::invokeMethod(q, [this] { + // FIXME why does connectAllSources() not work? + const QStringList allSources = dataEngine->sources(); + for (const QString &source : allSources) { + onSourceAdded(source); + } + }, Qt::QueuedConnection); + + QObject::connect(dataEngine, &Plasma::DataEngine::sourceAdded, q, [this](const QString &source) { + onSourceAdded(source); + }); + QObject::connect(dataEngine, &Plasma::DataEngine::sourceRemoved, q, [this](const QString &source) { + onSourceRemoved(source); + }); +} + +JobsModel::Private::~Private() +{ + qDeleteAll(jobs); + jobs.clear(); +} + +void JobsModel::Private::onSourceAdded(const QString &source) +{ + q->beginInsertRows(QModelIndex(), sources.count(), sources.count()); + sources.append(source); + jobs.insert(source, new Job(source)); + // must connect after adding so we process the initial update call + dataEngine->connectSource(source, q); + q->endInsertRows(); +} + +void JobsModel::Private::onSourceRemoved(const QString &source) +{ + const int row = sources.indexOf(source); + // Job tracking might have been disabled in the meantime, otherwise this would not be neccessary + if (row == -1) { + return; + } + + const QModelIndex idx = q->index(row, 0); + Job *job = jobs.value(source); + Q_ASSERT(job); + + dataEngine->disconnectSource(source, q); + + // When user canceled transfer, remove it from the model + if (job->error() == 1) { // KIO::ERR_USER_CANCELED + q->close(source); + return; + } + + // update timestamp + job->setUpdated(); + + // when it was hidden in history, bring it up again + job->setDismissed(false); + + emit q->dataChanged(idx, idx, { + Notifications::UpdatedRole, + Notifications::DismissedRole, + Notifications::TimeoutRole, + Notifications::ClosableRole + }); +} + +void JobsModel::Private::operationCall(const QString &source, const QString &operation) +{ + auto *service = dataEngine->serviceForSource(source); + if (!service) { + qWarning() << "Failed to get service for source" << source << "for operation" << operation; + return; + } + + auto *job = service->startOperationCall(service->operationDescription(operation)); + QObject::connect(job, &KJob::finished, service, &QObject::deleteLater); +} + +JobsModel::JobsModel() + : QAbstractListModel(nullptr) + , d(new Private(this)) +{ +} + +JobsModel::~JobsModel() = default; + +JobsModel::Ptr JobsModel::createJobsModel() +{ + static QWeakPointer s_instance; + if (!s_instance) { + QSharedPointer ptr(new JobsModel()); + s_instance = ptr.toWeakRef(); + return ptr; + } + return s_instance.toStrongRef(); +} + +void JobsModel::dataUpdated(const QString &sourceName, const Plasma::DataEngine::Data &data) +{ + const int row = d->sources.indexOf(sourceName); + if (row == -1) { + return; + } + + const auto dirtyRoles = d->jobs.value(sourceName)->processData(data); + + if (!dirtyRoles.isEmpty()) { + const QModelIndex idx = index(row, 0); + emit dataChanged(idx, idx, dirtyRoles); + } +} + +QVariant JobsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= d->jobs.count()) { + return QVariant(); + } + + const QString &sourceName = d->sources.at(index.row()); + const Job *job = d->jobs.value(sourceName); + + switch (role) { + case Notifications::IdRole: return job->sourceName(); + case Notifications::TypeRole: return Notifications::JobType; + // basically when it started + case Notifications::CreatedRole: + if (job->created().isValid()) { + return job->created(); + } + break; + // basically when it finished + case Notifications::UpdatedRole: + if (job->updated().isValid()) { + return job->updated(); + } + break; + case Notifications::SummaryRole: return job->summary(); + case Notifications::DesktopEntryRole: return job->desktopEntry(); + case Notifications::ApplicationNameRole: return job->applicationName(); + case Notifications::ApplicationIconNameRole: return job->applicationIconName(); + + case Notifications::JobStateRole: return job->state(); + case Notifications::PercentageRole: return job->percentage(); + case Notifications::ErrorRole: return job->error(); + case Notifications::ErrorTextRole: return job->errorText(); + case Notifications::SuspendableRole: return job->suspendable(); + case Notifications::KillableRole: return job->killable(); + case Notifications::JobDetailsRole: return QVariant::fromValue(job->details()); + + // successfully finished jobs timeout like a regular notifiation + // whereas running or error'd jobs are persistent + case Notifications::TimeoutRole: + return job->state() == Notifications::JobStateStopped && !job->error() ? -1 : 0; + case Notifications::ClosableRole: + return job->state() == Notifications::JobStateStopped; + + case Notifications::ConfigurableRole: return false; + case Notifications::ExpiredRole: return job->expired(); + case Notifications::DismissedRole: return job->dismissed(); + } + + return QVariant(); +} + +bool JobsModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (!index.isValid() || index.row() >= d->jobs.count()) { + return false; + } + + const QString &sourceName = d->sources.at(index.row()); + Job *job = d->jobs.value(sourceName); + + bool dirty = false; + + switch (role) { + case Notifications::DismissedRole: + if (value.toBool() != job->dismissed()) { + job->setDismissed(value.toBool()); + dirty = true; + } + break; + } + + if (dirty) { + emit dataChanged(index, index, {role}); + } + + return dirty; +} + +int JobsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return d->sources.count(); +} + +void JobsModel::close(const QString &jobId) +{ + const int row = d->sources.indexOf(jobId); + if (row == -1) { + return; + } + + beginRemoveRows(QModelIndex(), row, row); + d->sources.removeAt(row); + endRemoveRows(); + + delete d->jobs.take(jobId); +} + +void JobsModel::expire(const QString &jobId) +{ + const int row = d->sources.indexOf(jobId); + if (row == -1) { + return; + } + + const QModelIndex idx = index(row, 0); + + Job *job = d->jobs.value(jobId); + job->setExpired(true); + emit dataChanged(idx, idx, {Notifications::ExpiredRole}); +} + +void JobsModel::suspend(const QString &jobId) +{ + d->operationCall(jobId, QStringLiteral("suspend")); +} + +void JobsModel::resume(const QString &jobId) +{ + d->operationCall(jobId, QStringLiteral("resume")); +} + +void JobsModel::kill(const QString &jobId) +{ + d->operationCall(jobId, QStringLiteral("stop")); +} + +void JobsModel::clear(Notifications::ClearFlags flags) +{ + if (d->sources.isEmpty()) { + return; + } + + for (int i = d->sources.count() - 1; i >= 0; --i) { + const QString &sourceName = d->sources.at(i); + Job *job = d->jobs.value(sourceName); + + bool clear = (flags.testFlag(Notifications::ClearExpired) && job->expired()); + + // Compared to notifications, the number of jobs is typically small + // so for simplicity we can just delete one item at a time + if (clear) { + beginRemoveRows(QModelIndex(), i, i); + d->sources.removeAt(i); + endRemoveRows(); + + delete d->jobs.take(sourceName); + } + } +} diff --git a/libnotificationmanager/kcfg/badgesettings.kcfg b/libnotificationmanager/kcfg/badgesettings.kcfg new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/badgesettings.kcfg @@ -0,0 +1,14 @@ + + + + + + + true + + + + diff --git a/libnotificationmanager/kcfg/badgesettings.kcfgc b/libnotificationmanager/kcfg/badgesettings.kcfgc new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/badgesettings.kcfgc @@ -0,0 +1,7 @@ +File=badgesettings.kcfg +NameSpace=NotificationManager +ClassName=BadgeSettings +Singleton=true +Mutators=true +DefaultValueGetters=true +Notifiers=true diff --git a/libnotificationmanager/kcfg/donotdisturbsettings.kcfg b/libnotificationmanager/kcfg/donotdisturbsettings.kcfg new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/donotdisturbsettings.kcfg @@ -0,0 +1,17 @@ + + + + + + + + + + + false + + + diff --git a/libnotificationmanager/kcfg/donotdisturbsettings.kcfgc b/libnotificationmanager/kcfg/donotdisturbsettings.kcfgc new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/donotdisturbsettings.kcfgc @@ -0,0 +1,7 @@ +File=donotdisturbsettings.kcfg +NameSpace=NotificationManager +ClassName=DoNotDisturbSettings +Singleton=true +Mutators=true +DefaultValueGetters=true +Notifiers=true diff --git a/libnotificationmanager/kcfg/jobsettings.kcfg b/libnotificationmanager/kcfg/jobsettings.kcfg new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/jobsettings.kcfg @@ -0,0 +1,20 @@ + + + + + + + true + + + true + + + true + + + + diff --git a/libnotificationmanager/kcfg/jobsettings.kcfgc b/libnotificationmanager/kcfg/jobsettings.kcfgc new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/jobsettings.kcfgc @@ -0,0 +1,7 @@ +File=jobsettings.kcfg +NameSpace=NotificationManager +ClassName=JobSettings +Singleton=true +Mutators=true +DefaultValueGetters=true +Notifiers=true diff --git a/libnotificationmanager/kcfg/notificationsettings.kcfg b/libnotificationmanager/kcfg/notificationsettings.kcfg new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/notificationsettings.kcfg @@ -0,0 +1,38 @@ + + + + + + + true + + + true + + + true + + + false + + + + + + + + + + + + NearWidget + + + 5000 + + + + diff --git a/libnotificationmanager/kcfg/notificationsettings.kcfgc b/libnotificationmanager/kcfg/notificationsettings.kcfgc new file mode 100644 --- /dev/null +++ b/libnotificationmanager/kcfg/notificationsettings.kcfgc @@ -0,0 +1,10 @@ +File=notificationsettings.kcfg +NameSpace=NotificationManager +ClassName=NotificationSettings +Singleton=true +Mutators=true +DefaultValueGetters=true +Notifiers=true +# For Settings::PopupPosition +IncludeFiles=\"settings.h\" +UseEnumTypes=true diff --git a/libnotificationmanager/libnotificationmanager.categories b/libnotificationmanager/libnotificationmanager.categories new file mode 100644 --- /dev/null +++ b/libnotificationmanager/libnotificationmanager.categories @@ -0,0 +1,2 @@ +# Logging categories (for kdebugsettings) +org.kde.plasma.notifications Plasma Notifications diff --git a/libnotificationmanager/limitedrowcountproxymodel.cpp b/libnotificationmanager/limitedrowcountproxymodel.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/limitedrowcountproxymodel.cpp @@ -0,0 +1,74 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "limitedrowcountproxymodel_p.h" + +#include "notifications.h" + +using namespace NotificationManager; + +LimitedRowCountProxyModel::LimitedRowCountProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + +} + +LimitedRowCountProxyModel::~LimitedRowCountProxyModel() = default; + +void LimitedRowCountProxyModel::setSourceModel(QAbstractItemModel *sourceModel) +{ + if (this->sourceModel()) { + disconnect(this->sourceModel(), nullptr, this, nullptr); + } + + QSortFilterProxyModel::setSourceModel(sourceModel); + + if (sourceModel) { + connect(sourceModel, &QAbstractItemModel::rowsInserted, this, &LimitedRowCountProxyModel::invalidateFilter); + connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, &LimitedRowCountProxyModel::invalidateFilter); + } +} + +int LimitedRowCountProxyModel::limit() const +{ + return m_limit; +} + +void LimitedRowCountProxyModel::setLimit(int limit) +{ + if (m_limit != limit) { + m_limit = limit; + invalidateFilter(); + emit limitChanged(); + } +} + +bool LimitedRowCountProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + if (source_parent.isValid()) { + return true; + } + + if (m_limit > 0) { + return source_row < m_limit; + } + + return true; +} diff --git a/libnotificationmanager/limitedrowcountproxymodel_p.h b/libnotificationmanager/limitedrowcountproxymodel_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/limitedrowcountproxymodel_p.h @@ -0,0 +1,47 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include + +class LimitedRowCountProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + explicit LimitedRowCountProxyModel(QObject *parent = nullptr); + ~LimitedRowCountProxyModel() override; + + void setSourceModel(QAbstractItemModel *sourceModel) override; + + int limit() const; + void setLimit(int limit); + +signals: + void limitChanged(); + +protected: + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + +private: + int m_limit = 0; + +}; diff --git a/libnotificationmanager/notification.h b/libnotificationmanager/notification.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notification.h @@ -0,0 +1,124 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "notifications.h" + +#include "notificationmanager_export.h" + +namespace NotificationManager +{ + +/** + * @short Represents a single notification + * + * @author Kai Uwe Broulik + **/ +class NOTIFICATIONMANAGER_EXPORT Notification +{ +public: + explicit Notification(uint id = 0); + + Notification(const Notification &other); + Notification(Notification &&other) Q_DECL_NOEXCEPT; + + Notification &operator=(const Notification &other); + Notification &operator=(Notification &&other) Q_DECL_NOEXCEPT; + + // should this be virtual for good measure? + ~Notification(); + + uint id() const; + + QDateTime created() const; + + QDateTime updated() const; + void resetUpdated(); + + QString summary() const; + void setSummary(const QString &summary); + + QString body() const; + void setBody(const QString &body); + + QString iconName() const; + void setIconName(const QString &iconName); + + QImage image() const; + void setImage(const QImage &image); + + QString desktopEntry() const; + + QString notifyRcName() const; + QString eventId() const; + + QString applicationName() const; + void setApplicationName(const QString &applicationName); + + QString applicationIconName() const; + void setApplicationIconName(const QString &applicationIconName); + + QString deviceName() const; + + // should we group the two into a QPair or something? + QStringList actionNames() const; + QStringList actionLabels() const; + bool hasDefaultAction() const; + QString defaultActionLabel() const; + void setActions(const QStringList &actions); + + QList urls() const; + void setUrls(const QList &urls); + + // FIXME use separate enum again + Notifications::Urgency urgency() const; + void setUrgency(Notifications::Urgency urgency); + + int timeout() const; + void setTimeout(int timeout); + + bool configurable() const; + QString configureActionLabel() const; + + bool expired() const; + void setExpired(bool expired); + + bool dismissed() const; + void setDismissed(bool dismissed); + + void processHints(const QVariantMap &hints); + +private: + friend class NotificationsModel; + friend class ServerPrivate; + + class Private; + Private *d; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notification.cpp @@ -0,0 +1,608 @@ +/* + * Copyright 2008 Dmitry Suzdalev + * Copyright 2017 David Edmundson + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "notification.h" +#include "notification_p.h" + +#include "notifications.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "debug.h" + +#include "notifications.h" + +using namespace NotificationManager; + +Notification::Private::Private() +{ + +} + +Notification::Private::~Private() = default; + +QString Notification::Private::sanitize(const QString &text) +{ + // replace all \ns with
+ QString t = text; + + t.replace(QLatin1String("\n"), QStringLiteral("
")); + // Now remove all inner whitespace (\ns are already
s) + t = t.simplified(); + // Finally, check if we don't have multiple
s following, + // can happen for example when "\n \n" is sent, this replaces + // all
s in succsession with just one + t.replace(QRegularExpression(QStringLiteral("
\\s*
(\\s|
)*")), QLatin1String("
")); + // This fancy RegExp escapes every occurrence of & since QtQuick Text will blatantly cut off + // text where it finds a stray ampersand. + // Only &{apos, quot, gt, lt, amp}; as well as { character references will be allowed + t.replace(QRegularExpression(QStringLiteral("&(?!(?:apos|quot|[gl]t|amp);|#)")), QLatin1String("&")); + + // Don't bother adding some HTML structure if the body is now empty + if (t.isEmpty()) { + return t; + } + + QXmlStreamReader r(QStringLiteral("") + t + QStringLiteral("")); + QString result; + QXmlStreamWriter out(&result); + + const QVector allowedTags = {"b", "i", "u", "img", "a", "html", "br", "table", "tr", "td"}; + + out.writeStartDocument(); + while (!r.atEnd()) { + r.readNext(); + + if (r.tokenType() == QXmlStreamReader::StartElement) { + const QString name = r.name().toString(); + if (!allowedTags.contains(name)) { + continue; + } + out.writeStartElement(name); + if (name == QLatin1String("img")) { + auto src = r.attributes().value("src").toString(); + auto alt = r.attributes().value("alt").toString(); + + const QUrl url(src); + if (url.isLocalFile()) { + out.writeAttribute(QStringLiteral("src"), src); + } else { + //image denied for security reasons! Do not copy the image src here! + } + + out.writeAttribute(QStringLiteral("alt"), alt); + } + if (name == QLatin1String("a")) { + out.writeAttribute(QStringLiteral("href"), r.attributes().value("href").toString()); + } + } + + if (r.tokenType() == QXmlStreamReader::EndElement) { + const QString name = r.name().toString(); + if (!allowedTags.contains(name)) { + continue; + } + out.writeEndElement(); + } + + if (r.tokenType() == QXmlStreamReader::Characters) { + const auto text = r.text().toString(); + out.writeCharacters(text); //this auto escapes chars -> HTML entities + } + } + out.writeEndDocument(); + + if (r.hasError()) { + qCWarning(NOTIFICATIONMANAGER) << "Notification to send to backend contains invalid XML: " + << r.errorString() << "line" << r.lineNumber() + << "col" << r.columnNumber(); + } + + // The Text.StyledText format handles only html3.2 stuff and ' is html4 stuff + // so we need to replace it here otherwise it will not render at all. + result = result.replace(QLatin1String("'"), QChar('\'')); + + return result; +} + +QImage Notification::Private::decodeNotificationSpecImageHint(const QDBusArgument &arg) +{ + int width, height, rowStride, hasAlpha, bitsPerSample, channels; + QByteArray pixels; + char* ptr; + char* end; + + arg.beginStructure(); + arg >> width >> height >> rowStride >> hasAlpha >> bitsPerSample >> channels >> pixels; + arg.endStructure(); + + #define SANITY_CHECK(condition) \ + if (!(condition)) { \ + qWarning() << "Sanity check failed on" << #condition; \ + return QImage(); \ + } + + SANITY_CHECK(width > 0); + SANITY_CHECK(width < 2048); + SANITY_CHECK(height > 0); + SANITY_CHECK(height < 2048); + SANITY_CHECK(rowStride > 0); + + #undef SANITY_CHECK + + auto copyLineRGB32 = [](QRgb* dst, const char* src, int width) + { + const char* end = src + width * 3; + for (; src != end; ++dst, src+=3) { + *dst = qRgb(src[0], src[1], src[2]); + } + }; + + auto copyLineARGB32 = [](QRgb* dst, const char* src, int width) + { + const char* end = src + width * 4; + for (; src != end; ++dst, src+=4) { + *dst = qRgba(src[0], src[1], src[2], src[3]); + } + }; + + QImage::Format format = QImage::Format_Invalid; + void (*fcn)(QRgb*, const char*, int) = nullptr; + if (bitsPerSample == 8) { + if (channels == 4) { + format = QImage::Format_ARGB32; + fcn = copyLineARGB32; + } else if (channels == 3) { + format = QImage::Format_RGB32; + fcn = copyLineRGB32; + } + } + if (format == QImage::Format_Invalid) { + qWarning() << "Unsupported image format (hasAlpha:" << hasAlpha << "bitsPerSample:" << bitsPerSample << "channels:" << channels << ")"; + return QImage(); + } + + QImage image(width, height, format); + ptr = pixels.data(); + end = ptr + pixels.length(); + for (int y=0; y end) { + qWarning() << "Image data is incomplete. y:" << y << "height:" << height; + break; + } + fcn((QRgb*)image.scanLine(y), ptr, width); + } + + return image; +} + +QString Notification::Private::findImageForSpecImagePath(const QString &_path) +{ + QString path = _path; + if (path.startsWith(QLatin1String("file:"))) { + QUrl url(path); + path = url.toLocalFile(); + } + return KIconLoader::global()->iconPath(path, -KIconLoader::SizeHuge, + true /* canReturnNull */); +} + +QString Notification::Private::defaultComponentName() +{ + // NOTE Keep in sync with KNotification + return QStringLiteral("plasma_workspace"); +} + +void Notification::Private::processHints(const QVariantMap &hints) +{ + auto end = hints.end(); + + desktopEntry = hints.value(QStringLiteral("desktop-entry")).toString(); + + QString serviceName; + + configurableService = false; + if (!desktopEntry.isEmpty()) { + KService::Ptr service = KService::serviceByDesktopName(desktopEntry); + // Also try lower-case desktopEntry (Firefox sends "Firefox" which doesn't match "firefox"...) + if (!service) { + const QString lowerDesktopEntry = desktopEntry.toLower(); + service = KService::serviceByDesktopName(lowerDesktopEntry); + if (service) { + qCInfo(NOTIFICATIONMANAGER) << "Application sent desktop-entry" << desktopEntry << "but it actually was" << lowerDesktopEntry << ", this is an application bug!"; + desktopEntry = lowerDesktopEntry; + } + } + if (service) { + serviceName = service->name(); + applicationIconName = service->icon(); + configurableService = !service->noDisplay(); + } + } + + notifyRcName = hints.value(QStringLiteral("x-kde-appname")).toString(); + const bool isDefaultEvent = (notifyRcName == defaultComponentName()); + configurableNotifyRc = false; + if (!notifyRcName.isEmpty()) { + // Check whether the application actually has notifications we can configure + KConfig config(notifyRcName + QStringLiteral(".notifyrc"), KConfig::NoGlobals); + config.addConfigSources(QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, + QStringLiteral("knotifications5/") + notifyRcName + QStringLiteral(".notifyrc"))); + + KConfigGroup globalGroup(&config, "Global"); + + const QString iconName = globalGroup.readEntry("IconName"); + + // For default events we try to show the application name from the desktop entry if possible + // This will have us show e.g. "Dr Konqi" instead of generic "Plasma Desktop" + if (isDefaultEvent && !serviceName.isEmpty()) { + applicationName = serviceName; + } + + // also only overwrite application icon name for non-default events (or if we don't have a service icon) + if (!iconName.isEmpty() && (!isDefaultEvent || applicationIconName.isEmpty())) { + applicationIconName = iconName; + } + + const QRegularExpression regexp(QStringLiteral("^Event/([^/]*)$")); + configurableNotifyRc = !config.groupList().filter(regexp).isEmpty(); + } + + // Special override for KDE Connect since the notification is sent by kdeconnectd + // but actually comes from a different app on the phone + const QString applicationDisplayName = hints.value(QStringLiteral("x-kde-display-app-name")).toString(); + if (!applicationDisplayName.isEmpty()) { + applicationName = applicationDisplayName; + } + + deviceName = hints.value(QStringLiteral("x-kde-device-name")).toString(); + + eventId = hints.value(QStringLiteral("x-kde-eventId")).toString(); + + bool ok; + const int urgency = hints.value(QStringLiteral("urgency")).toInt(&ok); // DBus type is actually "byte" + if (ok) { + // FIXME use separate enum again + switch (urgency) { + case 0: + setUrgency(Notifications::LowUrgency); + break; + case 1: + setUrgency(Notifications::NormalUrgency); + break; + case 2: + setUrgency(Notifications::CriticalUrgency); + break; + } + } + + urls = QUrl::fromStringList(hints.value(QStringLiteral("x-kde-urls")).toStringList()); + + // Underscored hints was in use in version 1.1 of the spec but has been + // replaced by dashed hints in version 1.2. We need to support it for + // users of the 1.2 version of the spec. + auto it = hints.find(QStringLiteral("image-data")); + if (it == end) { + it = hints.find(QStringLiteral("image_data")); + } + if (it == end) { + // This hint was in use in version 1.0 of the spec but has been + // replaced by "image_data" in version 1.1. We need to support it for + // users of the 1.0 version of the spec. + it = hints.find(QStringLiteral("icon_data")); + } + + if (it != end) { + image = decodeNotificationSpecImageHint(it->value()); + } + + if (image.isNull()) { + it = hints.find(QStringLiteral("image-path")); + if (it == end) { + it = hints.find(QStringLiteral("image_path")); + } + + if (it != end) { + const QString path = findImageForSpecImagePath(it->toString()); + if (!path.isEmpty()) { + image.load(path); + } + } + } +} + +void Notification::Private::setUrgency(Notifications::Urgency urgency) +{ + this->urgency = urgency; + + // Critical notifications must not time out + // TODO should we really imply this here and not on the view side? + // are there usecases for critical but can expire? + // "critical updates available"? + if (urgency == Notifications::CriticalUrgency) { + timeout = 0; + } +} + +Notification::Notification(uint id) + : d(new Private()) +{ + d->id = id; + d->created = QDateTime::currentDateTimeUtc(); +} + +Notification::Notification(const Notification &other) + : d(new Private(*other.d)) +{ + +} + +Notification::Notification(Notification &&other) + : d(other.d) +{ + other.d = nullptr; +} + +Notification &Notification::operator=(const Notification &other) +{ + d = new Private(*other.d); + return *this; +} + +Notification &Notification::operator=(Notification &&other) +{ + d = other.d; + other.d = nullptr; + return *this; +} + +Notification::~Notification() +{ + delete d; +} + +uint Notification::id() const +{ + return d->id; +} + +QDateTime Notification::created() const +{ + return d->created; +} + +QDateTime Notification::updated() const +{ + return d->updated; +} + +void Notification::resetUpdated() +{ + d->updated = QDateTime::currentDateTimeUtc(); +} + +QString Notification::summary() const +{ + return d->summary; +} + +void Notification::setSummary(const QString &summary) +{ + d->summary = summary; +} + +QString Notification::body() const +{ + return d->body; +} + +void Notification::setBody(const QString &body) +{ + d->body = Private::sanitize(body.trimmed()); +} + +QString Notification::iconName() const +{ + return d->iconName; +} + +void Notification::setIconName(const QString &iconName) +{ + d->iconName = iconName; +} + +QImage Notification::image() const +{ + return d->image; +} + +void Notification::setImage(const QImage &image) +{ + d->image = image; +} + +QString Notification::desktopEntry() const +{ + return d->desktopEntry; +} + +QString Notification::notifyRcName() const +{ + return d->notifyRcName; +} + +QString Notification::eventId() const +{ + return d->eventId; +} + +QString Notification::applicationName() const +{ + return d->applicationName; +} + +void Notification::setApplicationName(const QString &applicationName) +{ + d->applicationName = applicationName; +} + +QString Notification::applicationIconName() const +{ + return d->applicationIconName; +} + +void Notification::setApplicationIconName(const QString &applicationIconName) +{ + d->applicationIconName = applicationIconName; +} + +QString Notification::deviceName() const +{ + return d->deviceName; +} + +QStringList Notification::actionNames() const +{ + return d->actionNames; +} + +QStringList Notification::actionLabels() const +{ + return d->actionLabels; +} + +bool Notification::hasDefaultAction() const +{ + return d->hasDefaultAction; +} + +QString Notification::defaultActionLabel() const +{ + return d->defaultActionLabel; +} + +void Notification::setActions(const QStringList &actions) +{ + if (actions.count() % 2 != 0) { + qCWarning(NOTIFICATIONMANAGER) << "List of actions must contain an even number of items, tried to set actions to" << actions; + return; + } + + d->hasDefaultAction = false; + d->hasConfigureAction = false; + + QStringList names; + QStringList labels; + + for (int i = 0; i < actions.count(); i += 2) { + const QString &name = actions.at(i); + const QString &label = actions.at(i + 1); + + if (!d->hasDefaultAction && name == QLatin1String("default")) { + d->hasDefaultAction = true; + d->defaultActionLabel = label; + continue; + } + + if (!d->hasConfigureAction && name == QLatin1String("settings")) { + d->hasConfigureAction = true; + d->configureActionLabel = label; + continue; + } + + names << name; + labels << label; + } + + d->actionNames = names; + d->actionLabels = labels; +} + +QList Notification::urls() const +{ + return d->urls; +} + +void Notification::setUrls(const QList &urls) +{ + d->urls = urls; +} + +Notifications::Urgency Notification::urgency() const +{ + return d->urgency; +} + +int Notification::timeout() const +{ + return d->timeout; +} + +void Notification::setTimeout(int timeout) +{ + d->timeout = timeout; +} + +bool Notification::configurable() const +{ + return d->hasConfigureAction || d->configurableNotifyRc || d->configurableService; +} + +QString Notification::configureActionLabel() const +{ + return d->configureActionLabel; +} + +bool Notification::expired() const +{ + return d->expired; +} + +void Notification::setExpired(bool expired) +{ + d->expired = expired; +} + +bool Notification::dismissed() const +{ + return d->dismissed; +} + +void Notification::setDismissed(bool dismissed) +{ + d->dismissed = dismissed; +} + +void Notification::processHints(const QVariantMap &hints) +{ + d->processHints(hints); +} diff --git a/libnotificationmanager/notification_p.h b/libnotificationmanager/notification_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notification_p.h @@ -0,0 +1,89 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "notifications.h" + +namespace NotificationManager +{ + +class Q_DECL_HIDDEN Notification::Private +{ +public: + Private(); + ~Private(); + + static QString sanitize(const QString &text); + static QImage decodeNotificationSpecImageHint(const QDBusArgument &arg); + static QString findImageForSpecImagePath(const QString &_path); + + static QString defaultComponentName(); + + void processHints(const QVariantMap &hints); + + void setUrgency(Notifications::Urgency urgency); + + uint id = 0; + QDateTime created; + QDateTime updated; + + QString summary; + QString body; + QString iconName; + QImage image; + + QString applicationName; + QString desktopEntry; + bool configurableService = false; + QString applicationIconName; + + QString deviceName; + + QStringList actionNames; + QStringList actionLabels; + bool hasDefaultAction = false; + QString defaultActionLabel; + + bool hasConfigureAction = false; + QString configureActionLabel; + + bool configurableNotifyRc = false; + QString notifyRcName; + QString eventId; + + QList urls; + + Notifications::Urgency urgency = Notifications::NormalUrgency; + int timeout = -1; + + bool expired = false; + bool dismissed = false; +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/notificationfilterproxymodel.cpp b/libnotificationmanager/notificationfilterproxymodel.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationfilterproxymodel.cpp @@ -0,0 +1,181 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "notificationfilterproxymodel_p.h" + +using namespace NotificationManager; + +NotificationFilterProxyModel::NotificationFilterProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setRecursiveFilteringEnabled(true); +} + +NotificationFilterProxyModel::~NotificationFilterProxyModel() = default; + +Notifications::Urgencies NotificationFilterProxyModel::urgencies() const +{ + return m_urgencies; +} + +void NotificationFilterProxyModel::setUrgencies(Notifications::Urgencies urgencies) +{ + if (m_urgencies != urgencies) { + m_urgencies = urgencies; + invalidateFilter(); + emit urgenciesChanged(); + } +} + +bool NotificationFilterProxyModel::showExpired() const +{ + return m_showExpired; +} + +void NotificationFilterProxyModel::setShowExpired(bool show) +{ + if (m_showExpired != show) { + m_showExpired = show; + invalidateFilter(); + emit showExpiredChanged(); + } +} + +bool NotificationFilterProxyModel::showDismissed() const +{ + return m_showDismissed; +} + +void NotificationFilterProxyModel::setShowDismissed(bool show) +{ + if (m_showDismissed != show) { + m_showDismissed = show; + invalidateFilter(); + emit showDismissedChanged(); + } +} + +QStringList NotificationFilterProxyModel::blacklistedDesktopEntries() const +{ + return m_blacklistedDesktopEntries; +} + +void NotificationFilterProxyModel::setBlackListedDesktopEntries(const QStringList &blacklist) +{ + if (m_blacklistedDesktopEntries != blacklist) { + m_blacklistedDesktopEntries = blacklist; + invalidateFilter(); + emit blacklistedDesktopEntriesChanged(); + } +} + +QStringList NotificationFilterProxyModel::blacklistedNotifyRcNames() const +{ + return m_blacklistedNotifyRcNames; +} + +void NotificationFilterProxyModel::setBlacklistedNotifyRcNames(const QStringList &blacklist) +{ + if (m_blacklistedNotifyRcNames != blacklist) { + m_blacklistedNotifyRcNames = blacklist; + invalidateFilter(); + emit blacklistedNotifyRcNamesChanged(); + } +} + +QStringList NotificationFilterProxyModel::whitelistedDesktopEntries() const +{ + return m_whitelistedDesktopEntries; +} + +void NotificationFilterProxyModel::setWhiteListedDesktopEntries(const QStringList &whitelist) +{ + if (m_whitelistedDesktopEntries != whitelist) { + m_whitelistedDesktopEntries = whitelist; + invalidateFilter(); + emit whitelistedDesktopEntriesChanged(); + } +} + +QStringList NotificationFilterProxyModel::whitelistedNotifyRcNames() const +{ + return m_whitelistedNotifyRcNames; +} + +void NotificationFilterProxyModel::setWhitelistedNotifyRcNames(const QStringList &whitelist) +{ + if (m_whitelistedNotifyRcNames != whitelist) { + m_whitelistedNotifyRcNames = whitelist; + invalidateFilter(); + emit whitelistedNotifyRcNamesChanged(); + } +} + +bool NotificationFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + const QModelIndex sourceIdx = sourceModel()->index(source_row, 0, source_parent); + + if (!m_showExpired && sourceIdx.data(Notifications::ExpiredRole).toBool()) { + return false; + } + + if (!m_showDismissed && sourceIdx.data(Notifications::DismissedRole).toBool()) { + return false; + } + + // Blacklist takes precedence over whitelist, i.e. when in doubt don't show + if (!m_blacklistedDesktopEntries.isEmpty()) { + const QString desktopEntry = sourceIdx.data(Notifications::DesktopEntryRole).toString(); + if (!desktopEntry.isEmpty() && m_blacklistedDesktopEntries.contains(desktopEntry)) { + return false; + } + } + + if (!m_blacklistedNotifyRcNames.isEmpty()) { + const QString notifyRcName = sourceIdx.data(Notifications::NotifyRcNameRole).toString(); + if (!notifyRcName.isEmpty() && m_blacklistedNotifyRcNames.contains(notifyRcName)) { + return false; + } + } + + if (!m_whitelistedDesktopEntries.isEmpty()) { + const QString desktopEntry = sourceIdx.data(Notifications::DesktopEntryRole).toString(); + if (!desktopEntry.isEmpty() && m_whitelistedDesktopEntries.contains(desktopEntry)) { + return true; + } + } + + if (!m_whitelistedNotifyRcNames.isEmpty()) { + const QString notifyRcName = sourceIdx.data(Notifications::NotifyRcNameRole).toString(); + if (!notifyRcName.isEmpty() && m_whitelistedNotifyRcNames.contains(notifyRcName)) { + return true; + } + } + + bool ok; + const auto urgency = static_cast(sourceIdx.data(Notifications::UrgencyRole).toInt(&ok)); + if (ok) { + if (!m_urgencies.testFlag(urgency)) { + return false; + } + } + + return true; +} diff --git a/libnotificationmanager/notificationfilterproxymodel_p.h b/libnotificationmanager/notificationfilterproxymodel_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationfilterproxymodel_p.h @@ -0,0 +1,87 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include + +#include "notifications.h" + +namespace NotificationManager +{ + +class NotificationFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + explicit NotificationFilterProxyModel(QObject *parent = nullptr); + ~NotificationFilterProxyModel() override; + + Notifications::Urgencies urgencies() const; + void setUrgencies(Notifications::Urgencies urgencies); + + bool showExpired() const; + void setShowExpired(bool show); + + bool showDismissed() const; + void setShowDismissed(bool show); + + QStringList blacklistedDesktopEntries() const; + void setBlackListedDesktopEntries(const QStringList &blacklist); + + QStringList blacklistedNotifyRcNames() const; + void setBlacklistedNotifyRcNames(const QStringList &blacklist); + + QStringList whitelistedDesktopEntries() const; + void setWhiteListedDesktopEntries(const QStringList &whitelist); + + QStringList whitelistedNotifyRcNames() const; + void setWhitelistedNotifyRcNames(const QStringList &whitelist); + +signals: + void urgenciesChanged(); + void showExpiredChanged(); + void showDismissedChanged(); + void blacklistedDesktopEntriesChanged(); + void blacklistedNotifyRcNamesChanged(); + void whitelistedDesktopEntriesChanged(); + void whitelistedNotifyRcNamesChanged(); + +protected: + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + +private: + Notifications::Urgencies m_urgencies = Notifications::LowUrgency + | Notifications::NormalUrgency + | Notifications::CriticalUrgency; + bool m_showDismissed = false; + bool m_showExpired = false; + + QStringList m_blacklistedDesktopEntries; + QStringList m_blacklistedNotifyRcNames; + + QStringList m_whitelistedDesktopEntries; + QStringList m_whitelistedNotifyRcNames; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/notificationgroupcollapsingproxymodel.cpp b/libnotificationmanager/notificationgroupcollapsingproxymodel.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationgroupcollapsingproxymodel.cpp @@ -0,0 +1,158 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "notificationgroupcollapsingproxymodel_p.h" + +#include "notifications.h" + +#include "debug.h" + +using namespace NotificationManager; + +NotificationGroupCollapsingProxyModel::NotificationGroupCollapsingProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + +} + +NotificationGroupCollapsingProxyModel::~NotificationGroupCollapsingProxyModel() = default; + +void NotificationGroupCollapsingProxyModel::setSourceModel(QAbstractItemModel *source) +{ + if (sourceModel()) { + disconnect(sourceModel(), nullptr, this, nullptr); + } + + QSortFilterProxyModel::setSourceModel(source); + + if (source) { + connect(source, &QAbstractItemModel::rowsInserted, this, &NotificationGroupCollapsingProxyModel::invalidateFilter); + connect(source, &QAbstractItemModel::rowsRemoved, this, &NotificationGroupCollapsingProxyModel::invalidateFilter); + + // When a group is removed, there is no item that's being removed, instead the item morphs back into a single notification + connect(source, &QAbstractItemModel::dataChanged, this, [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { + Q_UNUSED(bottomRight); // what about it? + Q_UNUSED(roles); + + if (roles.isEmpty() || roles.contains(Notifications::IsGroupRole)) { + if (!topLeft.data(Notifications::IsGroupRole).toBool()) { + const QModelIndex proxyIdx = mapFromSource(topLeft); + if (m_expandedGroups.contains(proxyIdx)) { + setGroupExpanded(proxyIdx, false); + } + } + } + }); + } +} + +QVariant NotificationGroupCollapsingProxyModel::data(const QModelIndex &index, int role) const +{ + if (role == NotificationManager::Notifications::IsGroupExpandedRole) { + if (m_limit > 0) { + // so each item in a group knows whether the group is expanded + const QModelIndex parentIdx = index.parent(); + if (parentIdx.isValid()) { + return m_expandedGroups.contains(parentIdx); + } else { + return m_expandedGroups.contains(index); + } + } else { + return true; + } + } + + return QSortFilterProxyModel::data(index, role); +} + +bool NotificationGroupCollapsingProxyModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (role == Notifications::IsGroupExpandedRole && m_limit > 0) { + QModelIndex groupIdx = index; + // so an item inside a group can expand/collapse the group + if (groupIdx.parent().isValid()) { + groupIdx = groupIdx.parent(); + } + + const bool expanded = value.toBool(); + if (!groupIdx.data(Notifications::IsGroupRole).toBool()) { + qCWarning(NOTIFICATIONMANAGER) << "Cannot" << (expanded ? "expand" : "collapse") << "an item isn't a group or inside of one"; + return false; + } + + return setGroupExpanded(groupIdx, expanded); + } + + return false; +} + +int NotificationGroupCollapsingProxyModel::limit() const +{ + return m_limit; +} + +void NotificationGroupCollapsingProxyModel::setLimit(int limit) +{ + if (m_limit != limit) { + m_limit = limit; + invalidateFilter(); + emit limitChanged(); + } +} + +bool NotificationGroupCollapsingProxyModel::setGroupExpanded(const QModelIndex &idx, bool expanded) +{ + if (idx.data(Notifications::IsGroupExpandedRole).toBool() == expanded) { + return false; + } + + QPersistentModelIndex persistentIdx(idx); + if (expanded) { + m_expandedGroups.append(persistentIdx); + } else { + m_expandedGroups.removeOne(persistentIdx); + } + + invalidateFilter(); + + emit dataChanged(idx, idx, {Notifications::IsGroupExpandedRole}); + + // also signal the children + emit dataChanged(idx.child(0, 0), idx.child(rowCount(idx) - 1, 0), {Notifications::IsGroupExpandedRole}); + + return true; +} + +bool NotificationGroupCollapsingProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + if (source_parent.isValid() && m_limit > 0) { + if (!m_expandedGroups.isEmpty() && m_expandedGroups.contains(mapFromSource(source_parent))) { + return true; + } + + // should we raise the limit when there's just one group? + + // FIXME why is this reversed? + // grouping proxy model seems to reverse the order? + return source_row >= sourceModel()->rowCount(source_parent) - m_limit; + } + + return true; +} diff --git a/libnotificationmanager/notificationgroupcollapsingproxymodel_p.h b/libnotificationmanager/notificationgroupcollapsingproxymodel_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationgroupcollapsingproxymodel_p.h @@ -0,0 +1,57 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include + +namespace NotificationManager { + +class NotificationGroupCollapsingProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + explicit NotificationGroupCollapsingProxyModel(QObject *parent = nullptr); + ~NotificationGroupCollapsingProxyModel() override; + + void setSourceModel(QAbstractItemModel *sourceModel) override; + + QVariant data(const QModelIndex &index, int role) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role) override; + + int limit() const; + void setLimit(int limit); + +signals: + void limitChanged(); + +protected: + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + +private: + bool setGroupExpanded(const QModelIndex &idx, bool expanded); + + int m_limit; + QList m_expandedGroups; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/notificationgroupingproxymodel.cpp b/libnotificationmanager/notificationgroupingproxymodel.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationgroupingproxymodel.cpp @@ -0,0 +1,523 @@ +/* + * Copyright 2016 Eike Hein + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "notificationgroupingproxymodel_p.h" + +#include + +#include "notifications.h" + +using namespace NotificationManager; + +NotificationGroupingProxyModel::NotificationGroupingProxyModel(QObject *parent) + : QAbstractProxyModel(parent) +{ + +} + +NotificationGroupingProxyModel::~NotificationGroupingProxyModel() = default; + +bool NotificationGroupingProxyModel::appsMatch(const QModelIndex &a, const QModelIndex &b) const +{ + const QString aName = a.data(Notifications::ApplicationNameRole).toString(); + const QString bName = b.data(Notifications::ApplicationNameRole).toString(); + + const QString aDesktopEntry = a.data(Notifications::DesktopEntryRole).toString(); + const QString bDesktopEntry = b.data(Notifications::DesktopEntryRole).toString(); + + return aName == bName && aDesktopEntry == bDesktopEntry; +} + +bool NotificationGroupingProxyModel::isGroup(int row) const +{ + if (row < 0 || row >= rowMap.count()) { + return false; + } + + return (rowMap.at(row)->count() > 1); +} + +bool NotificationGroupingProxyModel::tryToGroup(const QModelIndex &sourceIndex, bool silent) +{ + // Meat of the matter: Try to add this source row to a sub-list with source rows + // associated with the same application. + for (int i = 0; i < rowMap.count(); ++i) { + const QModelIndex &groupRep = sourceModel()->index(rowMap.at(i)->constFirst(), 0); + + // Don't match a row with itself. + if (sourceIndex == groupRep) { + continue; + } + + if (appsMatch(sourceIndex, groupRep)) { + const QModelIndex parent = index(i, 0); + + if (!silent) { + const int newIndex = rowMap.at(i)->count(); + + if (newIndex == 1) { + beginInsertRows(parent, 0, 1); + } else { + beginInsertRows(parent, newIndex, newIndex); + } + } + + rowMap[i]->append(sourceIndex.row()); + + if (!silent) { + endInsertRows(); + + dataChanged(parent, parent); + } + + return true; + } + } + + return false; +} + +void NotificationGroupingProxyModel::adjustMap(int anchor, int delta) +{ + for (int i = 0; i < rowMap.count(); ++i) { + QVector *sourceRows = rowMap.at(i); + QMutableVectorIterator it(*sourceRows); + + while (it.hasNext()) { + it.next(); + + if (it.value() >= anchor) { + it.setValue(it.value() + delta); + } + } + } +} + +void NotificationGroupingProxyModel::rebuildMap() +{ + qDeleteAll(rowMap); + rowMap.clear(); + + const int rows = sourceModel()->rowCount(); + + rowMap.reserve(rows); + + for (int i = 0; i < rows; ++i) { + rowMap.append(new QVector{i}); + } + + checkGrouping(true /* silent */); +} + +void NotificationGroupingProxyModel::checkGrouping(bool silent) +{ + for (int i = (rowMap.count()) - 1; i >= 0; --i) { + if (isGroup(i)) { + continue; + } + + // FIXME support skip grouping hint, maybe? + // The new grouping keeps every notification separate, still, so perhaps we don't need to + + if (tryToGroup(sourceModel()->index(rowMap.at(i)->constFirst(), 0), silent)) { + beginRemoveRows(QModelIndex(), i, i); + delete rowMap.takeAt(i); // Safe since we're iterating backwards. + endRemoveRows(); + } + } +} + +void NotificationGroupingProxyModel::formGroupFor(const QModelIndex &index) +{ + // Already in group or a group. + if (index.parent().isValid() || isGroup(index.row())) { + return; + } + + // We need to grab a source index as we may invalidate the index passed + // in through grouping. + const QModelIndex &sourceTarget = mapToSource(index); + + for (int i = (rowMap.count() - 1); i >= 0; --i) { + const QModelIndex &sourceIndex = sourceModel()->index(rowMap.at(i)->constFirst(), 0); + + if (!appsMatch(sourceTarget, sourceIndex)) { + continue; + } + + if (tryToGroup(sourceIndex)) { + beginRemoveRows(QModelIndex(), i, i); + delete rowMap.takeAt(i); // Safe since we're iterating backwards. + endRemoveRows(); + } + } +} + +void NotificationGroupingProxyModel::setSourceModel(QAbstractItemModel *sourceModel) +{ + if (sourceModel == QAbstractProxyModel::sourceModel()) { + return; + } + + beginResetModel(); + + if (QAbstractProxyModel::sourceModel()) { + QAbstractProxyModel::sourceModel()->disconnect(this); + } + + QAbstractProxyModel::setSourceModel(sourceModel); + + if (sourceModel) { + rebuildMap(); + + // FIXME move this stuff into separate slot methods + + connect(sourceModel, &QAbstractItemModel::rowsInserted, this, [this](const QModelIndex &parent, int start, int end) { + if (parent.isValid()) { + return; + } + + adjustMap(start, (end - start) + 1); + + for (int i = start; i <= end; ++i) { + if (!tryToGroup(this->sourceModel()->index(i, 0))) { + beginInsertRows(QModelIndex(), rowMap.count(), rowMap.count()); + rowMap.append(new QVector{i}); + endInsertRows(); + } + } + + checkGrouping(); + }); + + connect(sourceModel, &QAbstractItemModel::rowsAboutToBeRemoved, this, [this](const QModelIndex &parent, int first, int last) { + if (parent.isValid()) { + return; + } + + for (int i = first; i <= last; ++i) { + for (int j = 0; j < rowMap.count(); ++j) { + const QVector *sourceRows = rowMap.at(j); + const int mapIndex = sourceRows->indexOf(i); + + if (mapIndex != -1) { + // Remove top-level item. + if (sourceRows->count() == 1) { + beginRemoveRows(QModelIndex(), j, j); + delete rowMap.takeAt(j); + endRemoveRows(); + // Dissolve group. + } else if (sourceRows->count() == 2) { + const QModelIndex parent = index(j, 0); + beginRemoveRows(parent, 0, 1); + rowMap[j]->remove(mapIndex); + endRemoveRows(); + + // We're no longer a group parent. + dataChanged(parent, parent); + // Remove group member. + } else { + const QModelIndex parent = index(j, 0); + beginRemoveRows(parent, mapIndex, mapIndex); + rowMap[j]->remove(mapIndex); + endRemoveRows(); + + // Various roles of the parent evaluate child data, and the + // child list has changed. + dataChanged(parent, parent); + + // Signal children count change for all other items in the group. + emit dataChanged(index(0, 0, parent), index(rowMap.count() - 1, 0, parent), {Notifications::GroupChildrenCountRole}); + } + + break; + } + } + } + }); + + connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, [this](const QModelIndex &parent, int start, int end) { + if (parent.isValid()) { + return; + } + + adjustMap(start + 1, -((end - start) + 1)); + + checkGrouping(); + }); + + + connect(sourceModel, &QAbstractItemModel::modelAboutToBeReset, this, &NotificationGroupingProxyModel::beginResetModel); + connect(sourceModel, &QAbstractItemModel::modelReset, this, [this] { + rebuildMap(); + endResetModel(); + }); + + connect(sourceModel, &QAbstractItemModel::dataChanged, this, [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { + for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { + const QModelIndex &sourceIndex = this->sourceModel()->index(i, 0); + QModelIndex proxyIndex = mapFromSource(sourceIndex); + + if (!proxyIndex.isValid()) { + return; + } + + const QModelIndex parent = proxyIndex.parent(); + + // If a child item changes, its parent may need an update as well as many of + // the data roles evaluate child data. See data(). + // TODO: Some roles do not need to bubble up as they fall through to the first + // child in data(); it _might_ be worth adding constraints here later. + if (parent.isValid()) { + dataChanged(parent, parent, roles); + } + + dataChanged(proxyIndex, proxyIndex, roles); + } + }); + } + + endResetModel(); +} + +QModelIndex NotificationGroupingProxyModel::index(int row, int column, const QModelIndex &parent) const +{ + if (row < 0 || column != 0) { + return QModelIndex(); + } + + if (parent.isValid() && row < rowMap.at(parent.row())->count()) { + return createIndex(row, column, rowMap.at(parent.row())); + } + + if (row < rowMap.count()) { + return createIndex(row, column, nullptr); + } + + return QModelIndex(); +} + +QModelIndex NotificationGroupingProxyModel::parent(const QModelIndex &child) const +{ + if (child.internalPointer() == nullptr) { + return QModelIndex(); + } else { + const int parentRow = rowMap.indexOf(static_cast *>(child.internalPointer())); + + if (parentRow != -1) { + return index(parentRow, 0); + } + + // If we were asked to find the parent for an internalPointer we can't + // locate, we have corrupted data: This should not happen. + Q_ASSERT(parentRow != -1); + } + + return QModelIndex(); +} + +QModelIndex NotificationGroupingProxyModel::mapFromSource(const QModelIndex &sourceIndex) const +{ + if (!sourceIndex.isValid() || sourceIndex.model() != sourceModel()) { + return QModelIndex(); + } + + for (int i = 0; i < rowMap.count(); ++i) { + const QVector *sourceRows = rowMap.at(i); + const int childIndex = sourceRows->indexOf(sourceIndex.row()); + const QModelIndex parent = index(i, 0); + + if (childIndex == 0) { + // If the sub-list we found the source row in is larger than 1 (i.e. part + // of a group, map to the logical child item instead of the parent item + // the source row also stands in for. The parent is therefore unreachable + // from mapToSource(). + if (isGroup(i)) { + return index(0, 0, parent); + // Otherwise map to the top-level item. + } else { + return parent; + } + } else if (childIndex != -1) { + return index(childIndex, 0, parent); + } + } + + return QModelIndex(); +} + +QModelIndex NotificationGroupingProxyModel::mapToSource(const QModelIndex &proxyIndex) const +{ + if (!proxyIndex.isValid() || proxyIndex.model() != this || !sourceModel()) { + return QModelIndex(); + } + + const QModelIndex &parent = proxyIndex.parent(); + + if (parent.isValid()) { + if (parent.row() < 0 || parent.row() >= rowMap.count()) { + return QModelIndex(); + } + + return sourceModel()->index(rowMap.at(parent.row())->at(proxyIndex.row()), 0); + } else { + // Group parents items therefore equate to the first child item; the source + // row logically appears twice in the proxy. + // mapFromSource() is not required to handle this well (consider proxies can + // filter out rows, too) and opts to map to the child item, as the group parent + // has its Qt::DisplayRole mangled by data(), and it's more useful for trans- + // lating dataChanged() from the source model. + // NOTE we changed that to be last + if (rowMap.isEmpty()) { // FIXME + // How can this happen? (happens when closing a group) + return QModelIndex(); + } + return sourceModel()->index(rowMap.at(proxyIndex.row())->constLast(), 0); + } + + return QModelIndex(); +} + +int NotificationGroupingProxyModel::rowCount(const QModelIndex &parent) const +{ + if (!sourceModel()) { + return 0; + } + + if (parent.isValid() && parent.model() == this) { + // Don't return row count for top-level item at child row: Group members + // never have further children of their own. + if (parent.parent().isValid()) { + return 0; + } + + if (parent.row() < 0 || parent.row() >= rowMap.count()) { + return 0; + } + + const int rowCount = rowMap.at(parent.row())->count(); + + // If this sub-list in the map only has one entry, it's a plain item, not + // parent to a group. + if (rowCount == 1) { + return 0; + } else { + return rowCount; + } + } + + return rowMap.count(); +} + +bool NotificationGroupingProxyModel::hasChildren(const QModelIndex &parent) const +{ + if ((parent.model() && parent.model() != this) || !sourceModel()) { + return false; + } + + return rowCount(parent); +} + +int NotificationGroupingProxyModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + return 1; +} + +QVariant NotificationGroupingProxyModel::data(const QModelIndex &proxyIndex, int role) const +{ + if (!proxyIndex.isValid() || proxyIndex.model() != this || !sourceModel()) { + return QVariant(); + } + + const QModelIndex &parent = proxyIndex.parent(); + const bool isGroup = (!parent.isValid() && this->isGroup(proxyIndex.row())); + + // For group parent items, this will map to the last child task. + const QModelIndex &sourceIndex = mapToSource(proxyIndex); + + if (!sourceIndex.isValid()) { + return QVariant(); + } + + if (isGroup) { + // For group parent items, DisplayRole is mapped to AppName of the first child. + switch (role) { + case Notifications::IsGroupRole: + return true; + case Notifications::GroupChildrenCountRole: + return rowCount(proxyIndex); + case Notifications::IsInGroupRole: + return false; + + case Notifications::DesktopEntryRole: + for (int i = 0; i < rowCount(proxyIndex); ++i) { + const QString desktopEntry = proxyIndex.child(i, 0).data(Notifications::DesktopEntryRole).toString(); + if (!desktopEntry.isEmpty()) { + return desktopEntry; + } + } + return QString(); + case Notifications::NotifyRcNameRole: + for (int i = 0; i < rowCount(proxyIndex); ++i) { + const QString notifyRcName = proxyIndex.child(i, 0).data(Notifications::NotifyRcNameRole).toString(); + if (!notifyRcName.isEmpty()) { + return notifyRcName; + } + } + return QString(); + + + case Notifications::ConfigurableRole: // if there is any configurable child item + for (int i = 0; i < rowCount(proxyIndex); ++i) { + if (proxyIndex.child(i, 0).data(Notifications::ConfigurableRole).toBool()) { + return true; + } + } + return false; + + case Notifications::ClosableRole: // if there is any closable child item + for (int i = 0; i < rowCount(proxyIndex); ++i) { + if (proxyIndex.child(i, 0).data(Notifications::ClosableRole).toBool()) { + return true; + } + } + return false; + + } + } else { + switch (role) { + case Notifications::IsGroupRole: + return false; + // So a notification knows with how many other items it is in a group + case Notifications::GroupChildrenCountRole: + if (proxyIndex.parent().isValid()) { + return rowCount(proxyIndex.parent()); + } + break; + case Notifications::IsInGroupRole: + return parent.isValid(); + } + } + + return sourceIndex.data(role); +} diff --git a/libnotificationmanager/notificationgroupingproxymodel_p.h b/libnotificationmanager/notificationgroupingproxymodel_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationgroupingproxymodel_p.h @@ -0,0 +1,68 @@ +/* + * Copyright 2016 Eike Hein + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + + +#pragma once + +#include + +namespace NotificationManager +{ + +class NotificationGroupingProxyModel : public QAbstractProxyModel +{ + Q_OBJECT + +public: + explicit NotificationGroupingProxyModel(QObject *parent = nullptr); + ~NotificationGroupingProxyModel() override; + + void setSourceModel(QAbstractItemModel *sourceModel) override; + + QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex &child) const override; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + bool hasChildren(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + + QVariant data(const QModelIndex &proxyIndex, int role) const override; + + QModelIndex mapFromSource(const QModelIndex &sourceIndex) const override; + QModelIndex mapToSource(const QModelIndex &proxyIndex) const override; + +protected: + //bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; + +private: + bool appsMatch(const QModelIndex &a, const QModelIndex &b) const; + bool isGroup(int row) const; + bool tryToGroup(const QModelIndex &sourceIndex, bool silent = false); + void adjustMap(int anchor, int delta); + void rebuildMap(); + void checkGrouping(bool silent = false); + void formGroupFor(const QModelIndex &index); + + QVector *> rowMap; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/notifications.h b/libnotificationmanager/notifications.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notifications.h @@ -0,0 +1,488 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include + +#include + +#include "notificationmanager_export.h" + +namespace NotificationManager +{ + +/** + * @brief A model with notifications and jobs + * + * This model contains application notifications as well as jobs + * and lets you apply fine-grained filter, sorting, and grouping rules. + * + * @author Kai Uwe Broulik + **/ +class NOTIFICATIONMANAGER_EXPORT Notifications : public QSortFilterProxyModel, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + /** + * The number of notifications the model should at most contain. + * + * Default is 0, which is no limit. + */ + Q_PROPERTY(int limit READ limit WRITE setLimit NOTIFY limitChanged) + + /** + * Whether to show expired notifications. + * + * Expired notifications are those that timed out, i.e. ones that were not explicitly + * closed or acted upon by the user, nor revoked by the issuing application. + * + * An expired notification has its actions removed. + * + * Default is false. + */ + Q_PROPERTY(bool showExpired READ showExpired WRITE setShowExpired NOTIFY showExpiredChanged) + + /** + * Whether to show dismissed notifications. + * + * Dismissed notifications are those that are temporarily hidden by the user. + * This can e.g. be a copy job that has its popup closed but still continues in the background. + * + * Default is false. + */ + Q_PROPERTY(bool showDismissed READ showDismissed WRITE setShowDismissed NOTIFY showDismissedChanged) + + /** + * A list of desktop entries for which no notifications should be shown. + * + * If the same desktop entry is present in both blacklist and whitelist, + * the blacklist takes precedence, i.e. the notification is not shown. + */ + Q_PROPERTY(QStringList blacklistedDesktopEntries READ blacklistedDesktopEntries WRITE setBlacklistedDesktopEntries NOTIFY blacklistedDesktopEntriesChanged) + + /** + * A list of notifyrc names for which no notifications should be shown. + * + * If the same notifyrc name is present in both blacklist and whitelist, + * the blacklist takes precedence, i.e. the notification is not shown. + */ + Q_PROPERTY(QStringList blacklistedNotifyRcNames READ blacklistedNotifyRcNames WRITE setBlacklistedNotifyRcNames NOTIFY blacklistedNotifyRcNamesChanged) + + /** + * A list of desktop entries for which notifications should be shown. + * + * This bypasses any filtering for urgency. + * + * If the same desktop entry is present in both whitelist and blacklist, + * the blacklist takes precedence, i.e. the notification is not shown. + * + * Default is empty list, which means normal filtering is applied. + */ + Q_PROPERTY(QStringList whitelistedDesktopEntries READ whitelistedDesktopEntries WRITE setWhitelistedDesktopEntries NOTIFY whitelistedDesktopEntriesChanged) + + /** + * A list of notifyrc names for which notifications should be shown. + * + * This bypasses any filtering for urgency. + * + * If the same notifyrc name is present in both whitelist and blacklist, + * the blacklist takes precedence, i.e. the notification is not shown. + * + * Default is empty list, which means normal filtering is applied. + */ + Q_PROPERTY(QStringList whitelistedNotifyRcNames READ whitelistedNotifyRcNames WRITE setWhitelistedNotifyRcNames NOTIFY whitelistedNotifyRcNamesChanged) + + /** + * Whether to show application jobs. + */ + Q_PROPERTY(bool showJobs READ showJobs WRITE setShowJobs NOTIFY showJobsChanged) + + /** + * The notification urgency types the model should contain. + * + * Default is all urgencies: low, normal, critical. + */ + Q_PROPERTY(Urgencies urgencies READ urgencies WRITE setUrgencies NOTIFY urgenciesChanged) + + /** + * The sort mode for notifications. + * + * Default is strictly by date created/updated. + */ + Q_PROPERTY(SortMode sortMode READ sortMode WRITE setSortMode NOTIFY sortModeChanged) + + /** + * The group mode for notifications. + * + * Default is ungrouped. + */ + Q_PROPERTY(GroupMode groupMode READ groupMode WRITE setGroupMode NOTIFY groupModeChanged) + + /** + * How many notifications are shown in each group. + * + * You can expand a group by setting the IsGroupExpandedRole to true. + * + * Default is 0, which means no limit. + */ + Q_PROPERTY(int groupLimit READ groupLimit WRITE setGroupLimit NOTIFY groupLimitChanged) + + /** + * The number of notifications in the model + */ + Q_PROPERTY(int count READ count NOTIFY countChanged) + + /** + * The number of active, i.e. non-expired notifications + */ + Q_PROPERTY(int activeNotificationsCount READ activeNotificationsCount NOTIFY activeNotificationsCountChanged) + + /** + * The number of inactive, i.e. non-expired notifications + */ + Q_PROPERTY(int expiredNotificationsCount READ expiredNotificationsCount NOTIFY expiredNotificationsCountChanged) + + /** + * The time when the user last could read the notifications. + * This is typically reset whenever the list of notifications is opened and is used to determine + * the @c unreadNotificationsCount + */ + Q_PROPERTY(QDateTime lastRead READ lastRead WRITE setLastRead RESET resetLastRead NOTIFY lastReadChanged) + + /** + * The number of notifications added since lastRead + * + * This can be used to show a "n unread notifications" label + */ + Q_PROPERTY(int unreadNotificationsCount READ unreadNotificationsCount NOTIFY unreadNotificationsCountChanged) + + /** + * The number of active jobs + */ + Q_PROPERTY(int activeJobsCount READ activeJobsCount NOTIFY activeJobsCountChanged) + /** + * The combined percentage of all jobs. + * + * This is the average of all percentages and could can be used to show + * a global progress bar. + */ + Q_PROPERTY(int jobsPercentage READ jobsPercentage NOTIFY jobsPercentageChanged) + +public: + explicit Notifications(QObject *parent = nullptr); + ~Notifications() override; + + enum Roles { + IdRole = Qt::UserRole + 1, ///< A notification identifier. This can be uint notification ID or string application job source. + SummaryRole = Qt::DisplayRole, ///< The notification summary. + ImageRole = Qt::DecorationRole, ///< The notification main image, which is not the application icon. Only valid for pixmap icons. + + IsGroupRole = Qt::UserRole + 2, ///< Whether the item is a group + GroupChildrenCountRole, ///< The number of children in a group. + IsGroupExpandedRole, ///< Whether the group is expanded, this role is writable. + + IsInGroupRole, ///< Whether the notification is currently inside a group. + TypeRole, ///< The type of model entry, either NotificationType or JobType. + CreatedRole, ///< When the notification was first created. + UpdatedRole, ///< When the notification was last updated, invalid when it hasn't been updated. + + BodyRole, ///< The notification body text. + IconNameRole, ///< The notification main icon, which is not the application icon. Only valid for icon names. + + DesktopEntryRole, ///< The desktop entry (without .desktop suffix, e.g. org.kde.spectacle) of the application that sent the notification. + NotifyRcNameRole, ///< The notifyrc name (e.g. spectaclerc) of the application that sent the notification. + + ApplicationNameRole, ///< The user-visible name of the application (e.g. Spectacle) + ApplicationIconNameRole, ///< The icon name of the application + DeviceNameRole, ///< The name of the device the notification originally came from, if it was proxied through a sync service like KDE Connect + + // Jobs + JobStateRole, ///< The state of the job, either JobStateJopped, JobStateSuspended, or JobStateRunning. + PercentageRole, ///< The percentage of the job. Use @c jobsPercentage to get a global percentage for all jobs. + ErrorRole, ///< The error id of the job, zero in case of no error. + ErrorTextRole, ///< The user-visible error string of the job, empty in case of no error. + SuspendableRole, ///< Whether the job can be suspended @sa suspendJob + KillableRole, ///< Whether the job can be killed/canceled @sa killJob + JobDetailsRole, ///< A pointer to a JobDetails item containing more detailed information about the job + + ActionNamesRole, ///< The IDs of the actions, excluding the default and settings action, e.g. [action1, action2] + ActionLabelsRole, ///< The user-visible labels of the actions, excluding the default and settings action, e.g. ["Accept", "Reject"] + HasDefaultActionRole, ///< Whether the notification has a default action, which is one that is invoked when the popup itself is clicked + DefaultActionLabelRole, ///< The user-visible label of the default action, typically not shown as the popup itself becomes clickable + + UrlsRole, ///< A list of URLs associated with the notification, e.g. a path to a screenshot that was just taken or image received + + UrgencyRole, ///< The notification urgency, either LowUrgency, NormalUrgency, or CriticalUrgency. Jobs do not have an urgency. + TimeoutRole, ///< The timeout for the notification in milliseconds. 0 means the notification should not timeout, -1 means a sensible default should be applied. + + ConfigurableRole, ///< Whether the notification can be configured because a desktopEntry or notifyRcName is known, or the notification has a setting action. @sa configure + ConfigureActionLabelRole, ///< The user-visible label for the settings action + ClosableRole, ///< Whether the item can be closed. Notifications are always closable, jobs are only when in JobStateStopped. + + ExpiredRole, ///< The notification timed out and closed. Actions on it cannot be invoked anymore. + DismissedRole ///< The notification got temporarily hidden by the user but could still be interacted with. + }; + + /** + * The type of model item. + */ + enum Type { + NoType, + NotificationType, ///< This item represents a notification. + JobType ///< This item represents an application job. + }; + Q_ENUM(Type) + + /** + * The notification urgency. + * + * @note jobs do not have an urgency, yet still might be above normal urgency notifications. + */ + enum Urgency { + // these don't match the spec's value + LowUrgency = 1 << 0, ///< The notification has low urgency, it is not important and may not be shown or added to a history. + NormalUrgency = 1 << 1, ///< The notification has normal urgency. This is also the default if no urgecny is supplied. + CriticalUrgency = 1 << 2 + }; + Q_ENUM(Urgency) + Q_DECLARE_FLAGS(Urgencies, Urgency) + Q_FLAG(Urgencies) + + /** + * Which items should be cleared in a call to @c clear + */ + enum ClearFlag { + ClearExpired = 1 << 1, + // TODO more + }; + Q_ENUM(ClearFlag) + Q_DECLARE_FLAGS(ClearFlags, ClearFlag) + Q_FLAG(ClearFlags) + + /** + * The state an application job is in. + */ + enum JobState { + JobStateStopped, ///< The job is stopped. It has either finished (error is 0) or failed (error is not 0) + JobStateRunning, ///< The job is currently running. + JobStateSuspended ///< The job is currentl paused + }; + Q_ENUM(JobState) + + /** + * The sort mode for the model. + */ + enum SortMode { + SortByDate = 0, ///< Sort notifications strictly by the date they were updated or created. + // should this be flags? SortJobsFirst | SortByUrgency | ...? + SortByTypeAndUrgency ///< Sort notifications taking into account their type and urgency. The order is (descending): Critical, jobs, Normal, Low. + }; + Q_ENUM(SortMode) + + /** + * The group mode for the model. + */ + enum GroupMode { + GroupDisabled = 0, + //GroupApplicationsTree, // TODO make actual tree + GroupApplicationsFlat + }; + Q_ENUM(GroupMode) + + int limit() const; + void setLimit(int limit); + + bool showExpired() const; + void setShowExpired(bool show); + + bool showDismissed() const; + void setShowDismissed(bool show); + + QStringList blacklistedDesktopEntries() const; + void setBlacklistedDesktopEntries(const QStringList &blacklist); + + QStringList blacklistedNotifyRcNames() const; + void setBlacklistedNotifyRcNames(const QStringList &blacklist); + + QStringList whitelistedDesktopEntries() const; + void setWhitelistedDesktopEntries(const QStringList &whitelist); + + QStringList whitelistedNotifyRcNames() const; + void setWhitelistedNotifyRcNames(const QStringList &whitelist); + + bool showJobs() const; + void setShowJobs(bool showJobs); + + Urgencies urgencies() const; + void setUrgencies(Urgencies urgencies); + + SortMode sortMode() const; + void setSortMode(SortMode sortMode); + + GroupMode groupMode() const; + void setGroupMode(GroupMode groupMode); + + int groupLimit() const; + void setGroupLimit(int limit); + + int count() const; + + int activeNotificationsCount() const; + int expiredNotificationsCount() const; + + QDateTime lastRead() const; + void setLastRead(const QDateTime &lastRead); + void resetLastRead(); + + int unreadNotificationsCount() const; + + int activeJobsCount() const; + int jobsPercentage() const; + + /** + * @brief Expire a notification + * + * Closes the notification in response to its timeout running out. + * + * Call this if you have an implementation that handles the timeout itself + * by having called @c stopTimeout + * + * @sa stopTimeout + */ + Q_INVOKABLE void expire(const QModelIndex &idx); + /** + * @brief Close a notification + * + * Closes the notification in response to the user explicitly closing it. + * + * When the model index belongs to a group, the entire group is closed. + */ + Q_INVOKABLE void close(const QModelIndex &idx); + /** + * @brief Configure a notification + * + * This will invoke the settings action, if available, otherwise open the + * kcm_notifications KCM for configuring the respective application and event. + */ + Q_INVOKABLE void configure(const QModelIndex &idx); // TODO pass ctx for transient handling + /** + * @brief Invoke the default notification action + * + * Invokes the action that should be triggered when clicking + * the notification bubble itself. + */ + Q_INVOKABLE void invokeDefaultAction(const QModelIndex &idx); + /** + * @brief Invoke a notification action + * + * Invokes the action with the given actionId on the notification. + * For invoking the default action, i.e. the one that is triggered + * when clicking the notification bubble, use invokeDefaultAction + */ + Q_INVOKABLE void invokeAction(const QModelIndex &idx, const QString &actionId); + + /** + * @brief Start automatic timeout of notifications + * + * Call this if you no longer handle the timeout yourself. + * + * @sa stopTimeout + */ + Q_INVOKABLE void startTimeout(const QModelIndex &idx); + + Q_INVOKABLE void startTimeout(uint notificationId); + /** + * @brief Stop the automatic timeout of notifications + * + * Call this if you have an implementation that handles the timeout itself + * taking into account e.g. whether the user is currently interacting with + * the notification to not close it under their mouse. Call @c expire + * once your custom timer has run out. + * + * @sa expire + */ + Q_INVOKABLE void stopTimeout(const QModelIndex &idx); + + /** + * @brief Suspend a job + */ + Q_INVOKABLE void suspendJob(const QModelIndex &idx); + /** + * @brief Resume a job + */ + Q_INVOKABLE void resumeJob(const QModelIndex &idx); + /** + * @brief Kill a job + */ + Q_INVOKABLE void killJob(const QModelIndex &idx); + + /** + * @brief Clear notifications + * + * Removes the notifications matching th ClearFlags from the model. + * This can be used for e.g. a "Clear History" action. + */ + Q_INVOKABLE void clear(ClearFlags flags); + + QVariant data(const QModelIndex &index, int role/* = Qt::DisplayRole*/) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role) override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QHash roleNames() const override; + + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; + +signals: + void limitChanged(); + void showExpiredChanged(); + void showDismissedChanged(); + void blacklistedDesktopEntriesChanged(); + void blacklistedNotifyRcNamesChanged(); + void whitelistedDesktopEntriesChanged(); + void whitelistedNotifyRcNamesChanged(); + void showJobsChanged(); + void urgenciesChanged(); + void sortModeChanged(); + void groupModeChanged(); + void groupLimitChanged(); + void countChanged(); + void activeNotificationsCountChanged(); + void expiredNotificationsCountChanged(); + void lastReadChanged(); + void unreadNotificationsCountChanged(); + void activeJobsCountChanged(); + void jobsPercentageChanged(); + +protected: + void classBegin() override; + void componentComplete() override; + +private: + class Private; + QScopedPointer d; + +}; + +} // namespace NotificationManager + +Q_DECLARE_OPERATORS_FOR_FLAGS(NotificationManager::Notifications::Urgencies) diff --git a/libnotificationmanager/notifications.cpp b/libnotificationmanager/notifications.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notifications.cpp @@ -0,0 +1,720 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "notifications.h" + +#include + +#include +#include + +#include "notificationsmodel.h" +#include "notificationfilterproxymodel_p.h" +#include "notificationsortproxymodel_p.h" +#include "notificationgroupingproxymodel_p.h" +#include "notificationgroupcollapsingproxymodel_p.h" +#include "limitedrowcountproxymodel_p.h" + +#include "jobsmodel.h" + +#include "settings.h" + +#include "notification.h" + +#include "debug.h" + +#include + +using namespace NotificationManager; + +class Q_DECL_HIDDEN Notifications::Private +{ +public: + explicit Private(Notifications *q); + ~Private(); + + void initModels(); + void updateCount(); + + bool showJobs = false; + + Notifications::GroupMode groupMode = Notifications::GroupDisabled; + int groupLimit = 0; + + int activeNotificationsCount = 0; + int expiredNotificationsCount = 0; + + int unreadNotificationsCount = 0; + + int activeJobsCount = 0; + int jobsPercentage = 0; + + static bool isGroup(const QModelIndex &idx); + static uint notificationId(const QModelIndex &idx); + static QString jobId(const QModelIndex &idx); + static QModelIndex mapToModel(const QModelIndex &idx, const QAbstractItemModel *model); + + NotificationsModel::Ptr notificationsModel; + JobsModel::Ptr jobsModel; + QSharedPointer settings() const; + + KConcatenateRowsProxyModel *notificationsAndJobsModel = nullptr; + + NotificationFilterProxyModel *filterModel = nullptr; + NotificationSortProxyModel *sortModel = nullptr; + NotificationGroupingProxyModel *groupingModel = nullptr; + NotificationGroupCollapsingProxyModel *groupCollapsingModel = nullptr; + KDescendantsProxyModel *flattenModel = nullptr; + + LimitedRowCountProxyModel *limiterModel = nullptr; + +private: + Notifications *q; +}; + +Notifications::Private::Private(Notifications *q) + : q(q) +{ + +} + +Notifications::Private::~Private() +{ + +} + +void Notifications::Private::initModels() +{ + /* The data flow is as follows: + * + * NotificationsModel JobsModel + * \\ / + * \\ / + * KConcatenateRowsProxyModel + * ||| + * ||| + * NotificationFilterProxyModel + * (filters by urgency, whitelist, etc) + * | + * | + * NotificationSortProxyModel + * (sorts by urgency, date, etc) + * | + * --- BEGIN: Only when grouping is enabled --- + * | + * NotificationGroupingProxyModel + * (turns list into tree grouped by app) + * //\\ + * //\\ + * NotificationGroupCollapsingProxyModel + * (limits number of tree leaves for expand/collapse feature) + * /\ + * /\ + * KDescendantsProxyModel + * (flattens tree back into a list for consumption in ListView) + * | + * --- END: Only when grouping is enabled --- + * | + * LimitedRowCountProxyModel + * (limits the total number of items in the model) + * | + * | + * \o/ <- Happy user seeing their notifications + */ + + if (!notificationsModel) { + notificationsModel = NotificationsModel::createNotificationsModel(); + connect(notificationsModel.data(), &NotificationsModel::lastReadChanged, q, [this] { + updateCount(); + emit q->lastReadChanged(); + }); + } + + if (!notificationsAndJobsModel) { + notificationsAndJobsModel = new KConcatenateRowsProxyModel(q); + notificationsAndJobsModel->addSourceModel(notificationsModel.data()); + } + + if (!filterModel) { + filterModel = new NotificationFilterProxyModel(); + connect(filterModel, &NotificationFilterProxyModel::urgenciesChanged, q, &Notifications::urgenciesChanged); + connect(filterModel, &NotificationFilterProxyModel::showExpiredChanged, q, &Notifications::showExpiredChanged); + connect(filterModel, &NotificationFilterProxyModel::showDismissedChanged, q, &Notifications::showDismissedChanged); + connect(filterModel, &NotificationFilterProxyModel::blacklistedDesktopEntriesChanged, q, &Notifications::blacklistedDesktopEntriesChanged); + connect(filterModel, &NotificationFilterProxyModel::blacklistedNotifyRcNamesChanged, q, &Notifications::blacklistedNotifyRcNamesChanged); + + connect(filterModel, &QAbstractItemModel::rowsInserted, q, [this] { + updateCount(); + }); + connect(filterModel, &QAbstractItemModel::rowsRemoved, q, [this] { + updateCount(); + }); + connect(filterModel, &QAbstractItemModel::dataChanged, q, [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { + Q_UNUSED(topLeft); + Q_UNUSED(bottomRight); + if (roles.isEmpty() + || roles.contains(Notifications::UpdatedRole) + || roles.contains(Notifications::ExpiredRole) + || roles.contains(Notifications::JobStateRole) + || roles.contains(Notifications::PercentageRole)) { + updateCount(); + } + }); + + filterModel->setSourceModel(notificationsAndJobsModel); + } + + if (!sortModel) { + sortModel = new NotificationSortProxyModel(q); + connect(sortModel, &NotificationSortProxyModel::sortModeChanged, q, &Notifications::sortModeChanged); + } + + if (!limiterModel) { + limiterModel = new LimitedRowCountProxyModel(q); + connect(limiterModel, &LimitedRowCountProxyModel::limitChanged, q, &Notifications::limitChanged); + } + + if (groupMode == GroupApplicationsFlat) { + if (!groupingModel) { + groupingModel = new NotificationGroupingProxyModel(q); + groupingModel->setSourceModel(filterModel); + } + + if (!groupCollapsingModel) { + groupCollapsingModel = new NotificationGroupCollapsingProxyModel(q); + groupCollapsingModel->setLimit(groupLimit); + groupCollapsingModel->setSourceModel(groupingModel); + } + + sortModel->setSourceModel(groupCollapsingModel); + + flattenModel = new KDescendantsProxyModel(q); + flattenModel->setSourceModel(sortModel); + + limiterModel->setSourceModel(flattenModel); + } else { + sortModel->setSourceModel(filterModel); + limiterModel->setSourceModel(sortModel); + delete flattenModel; + flattenModel = nullptr; + delete groupingModel; + groupingModel = nullptr; + } + + q->setSourceModel(limiterModel); +} + +void Notifications::Private::updateCount() +{ + int active = 0; + int expired = 0; + int unread = 0; + + int jobs = 0; + int totalPercentage = 0; + + // We want to get the numbers after main filtering (urgencies, whitelists, etc) + // but before any limiting or group limiting, hence asking the filterModel for advice + // at which point notifications and jobs also have already been merged + for (int i = 0; i < filterModel->rowCount(); ++i) { + const QModelIndex idx = filterModel->index(i, 0); + + if (idx.data(Notifications::ExpiredRole).toBool()) { + ++expired; + } else { + ++active; + } + + QDateTime date = idx.data(Notifications::UpdatedRole).toDateTime(); + if (!date.isValid()) { + date = idx.data(Notifications::CreatedRole).toDateTime(); + } + + if (date > notificationsModel->lastRead()) { + ++unread; + } + + if (idx.data(Notifications::TypeRole).toInt() == Notifications::JobType) { + if (idx.data(Notifications::JobStateRole).toInt() != Notifications::JobStateStopped) { + ++jobs; + + totalPercentage += idx.data(Notifications::PercentageRole).toInt(); + } + } + } + + if (activeNotificationsCount != active) { + activeNotificationsCount = active; + emit q->activeNotificationsCountChanged(); + } + if (expiredNotificationsCount != expired) { + expiredNotificationsCount = expired; + emit q->expiredNotificationsCountChanged(); + } + if (unreadNotificationsCount != unread) { + unreadNotificationsCount = unread; + emit q->unreadNotificationsCountChanged(); + } + if (activeJobsCount != jobs) { + activeJobsCount = jobs; + emit q->activeJobsCountChanged(); + } + + const int percentage = (jobs > 0 ? totalPercentage / jobs : 0); + if (jobsPercentage != percentage) { + jobsPercentage = percentage; + emit q->jobsPercentageChanged(); + } + + // TODO don't emit in dataChanged + emit q->countChanged(); +} + +bool Notifications::Private::isGroup(const QModelIndex &idx) +{ + return idx.data(Notifications::IsGroupRole).toBool(); +} + +uint Notifications::Private::notificationId(const QModelIndex &idx) +{ + return idx.data(Notifications::IdRole).toUInt(); +} + +QString Notifications::Private::jobId(const QModelIndex &idx) +{ + return idx.data(Notifications::IdRole).toString(); +} + +QModelIndex Notifications::Private::mapToModel(const QModelIndex &idx, const QAbstractItemModel *model) +{ + QModelIndex resolvedIdx = idx; + while (resolvedIdx.isValid() && resolvedIdx.model() != model) { + if (auto *proxyModel = qobject_cast(resolvedIdx.model())) { + resolvedIdx = proxyModel->mapToSource(resolvedIdx); + } else { + if (resolvedIdx.model() != model) { + resolvedIdx = QModelIndex(); // give up + } + } + } + return resolvedIdx; +} + +QSharedPointer Notifications::Private::settings() const +{ + static QWeakPointer s_instance; + if (!s_instance) { + QSharedPointer ptr(new Settings()); + s_instance = ptr.toWeakRef(); + return ptr; + } + return s_instance.toStrongRef(); +} + +Notifications::Notifications(QObject *parent) + : QSortFilterProxyModel(parent) + , d(new Private(this)) +{ + d->initModels(); +} + +Notifications::~Notifications() = default; + +// TODO why are we QQmlParserStatus if we don't use it? +void Notifications::classBegin() +{ + +} + +void Notifications::componentComplete() +{ + +} + +int Notifications::limit() const +{ + return d->limiterModel->limit(); +} + +void Notifications::setLimit(int limit) +{ + d->limiterModel->setLimit(limit); +} + +int Notifications::groupLimit() const +{ + return d->groupLimit; +} + +void Notifications::setGroupLimit(int limit) +{ + if (d->groupLimit == limit) { + return; + } + + d->groupLimit = limit; + if (d->groupCollapsingModel) { + d->groupCollapsingModel->setLimit(limit); + } + emit groupLimitChanged(); +} + +bool Notifications::showExpired() const +{ + return d->filterModel->showExpired(); +} + +void Notifications::setShowExpired(bool show) +{ + d->filterModel->setShowExpired(show); +} + +bool Notifications::showDismissed() const +{ + return d->filterModel->showDismissed(); +} + +void Notifications::setShowDismissed(bool show) +{ + d->filterModel->setShowDismissed(show); +} + +QStringList Notifications::blacklistedDesktopEntries() const +{ + return d->filterModel->blacklistedDesktopEntries(); +} + +void Notifications::setBlacklistedDesktopEntries(const QStringList &blacklist) +{ + d->filterModel->setBlackListedDesktopEntries(blacklist); +} + +QStringList Notifications::blacklistedNotifyRcNames() const +{ + return d->filterModel->blacklistedNotifyRcNames(); +} + +void Notifications::setBlacklistedNotifyRcNames(const QStringList &blacklist) +{ + d->filterModel->setBlacklistedNotifyRcNames(blacklist); +} + +QStringList Notifications::whitelistedDesktopEntries() const +{ + return d->filterModel->whitelistedDesktopEntries(); +} + +void Notifications::setWhitelistedDesktopEntries(const QStringList &whitelist) +{ + d->filterModel->setWhiteListedDesktopEntries(whitelist); +} + +QStringList Notifications::whitelistedNotifyRcNames() const +{ + return d->filterModel->whitelistedNotifyRcNames(); +} + +void Notifications::setWhitelistedNotifyRcNames(const QStringList &whitelist) +{ + d->filterModel->setWhitelistedNotifyRcNames(whitelist); +} + +bool Notifications::showJobs() const +{ + return d->showJobs; +} + +void Notifications::setShowJobs(bool showJobs) +{ + if (d->showJobs == showJobs) { + return; + } + + if (showJobs) { + d->jobsModel = JobsModel::createJobsModel(); + d->notificationsAndJobsModel->addSourceModel(d->jobsModel.data()); + } else { + d->notificationsAndJobsModel->removeSourceModel(d->jobsModel.data()); + d->jobsModel = nullptr; + } + d->showJobs = showJobs; +} + +Notifications::Urgencies Notifications::urgencies() const +{ + return d->filterModel->urgencies(); +} + +void Notifications::setUrgencies(Urgencies urgencies) +{ + d->filterModel->setUrgencies(urgencies); +} + +Notifications::SortMode Notifications::sortMode() const +{ + return d->sortModel->sortMode(); +} + +void Notifications::setSortMode(SortMode sortMode) +{ + d->sortModel->setSortMode(sortMode); +} + +Notifications::GroupMode Notifications::groupMode() const +{ + return d->groupMode; +} + +void Notifications::setGroupMode(GroupMode groupMode) +{ + if (d->groupMode != groupMode) { + d->groupMode = groupMode; + d->initModels(); + emit groupModeChanged(); + } +} + +int Notifications::count() const +{ + return rowCount(QModelIndex()); +} + +int Notifications::activeNotificationsCount() const +{ + return d->activeNotificationsCount; +} + +int Notifications::expiredNotificationsCount() const +{ + return d->expiredNotificationsCount; +} + +QDateTime Notifications::lastRead() const +{ + return d->notificationsModel->lastRead(); +} + +void Notifications::setLastRead(const QDateTime &lastRead) +{ + d->notificationsModel->setLastRead(lastRead); +} + +void Notifications::resetLastRead() +{ + setLastRead(QDateTime::currentDateTimeUtc()); +} + +int Notifications::unreadNotificationsCount() const +{ + return d->unreadNotificationsCount; +} + +int Notifications::activeJobsCount() const +{ + return d->activeJobsCount; +} + +int Notifications::jobsPercentage() const +{ + return d->jobsPercentage; +} + +void Notifications::expire(const QModelIndex &idx) +{ + switch (static_cast(idx.data(Notifications::TypeRole).toInt())) { + case Notifications::NotificationType: + d->notificationsModel->expire(Private::notificationId(idx)); + break; + case Notifications::JobType: + d->jobsModel->expire(Private::jobId(idx)); + break; + default: + Q_UNREACHABLE(); + } +} + +void Notifications::close(const QModelIndex &idx) +{ + if (idx.data(Notifications::IsGroupRole).toBool()) { + const QModelIndex groupIdx = Private::mapToModel(idx, d->groupingModel); + if (!groupIdx.isValid()) { + qCWarning(NOTIFICATIONMANAGER) << "Failed to find group model index for this item"; + return; + } + + Q_ASSERT(groupIdx.model() == d->groupingModel); + + const int childCount = d->groupingModel->rowCount(groupIdx); + for (int i = childCount - 1; i >= 0; --i) { + const QModelIndex childIdx = d->groupingModel->index(i, 0, groupIdx); + close(childIdx); + } + return; + } + + if (!idx.data(Notifications::ClosableRole).toBool()) { + return; + } + + switch (static_cast(idx.data(Notifications::TypeRole).toInt())) { + case Notifications::NotificationType: + d->notificationsModel->close(Private::notificationId(idx)); + break; + case Notifications::JobType: + d->jobsModel->close(Private::jobId(idx)); + break; + default: + Q_UNREACHABLE(); + } +} + +void Notifications::configure(const QModelIndex &idx) +{ + // For groups just configure the application, not the individual event + if (Private::isGroup(idx)) { + const QString desktopEntry = idx.data(Notifications::DesktopEntryRole).toString(); + const QString notifyRcName = idx.data(Notifications::NotifyRcNameRole).toString(); + + d->notificationsModel->configure(desktopEntry, notifyRcName, QString() /*eventId*/); + return; + } + + d->notificationsModel->configure(Private::notificationId(idx)); +} + +void Notifications::invokeDefaultAction(const QModelIndex &idx) +{ + d->notificationsModel->invokeDefaultAction(Private::notificationId(idx)); +} + +void Notifications::invokeAction(const QModelIndex &idx, const QString &actionId) +{ + d->notificationsModel->invokeAction(Private::notificationId(idx), actionId); +} + +void Notifications::startTimeout(const QModelIndex &idx) +{ + startTimeout(Private::notificationId(idx)); +} + +void Notifications::startTimeout(uint notificationId) +{ + d->notificationsModel->startTimeout(notificationId); +} + +void Notifications::stopTimeout(const QModelIndex &idx) +{ + d->notificationsModel->stopTimeout(Private::notificationId(idx)); +} + +void Notifications::suspendJob(const QModelIndex &idx) +{ + d->jobsModel->suspend(Private::jobId(idx)); +} + +void Notifications::resumeJob(const QModelIndex &idx) +{ + d->jobsModel->resume(Private::jobId(idx)); +} + +void Notifications::killJob(const QModelIndex &idx) +{ + d->jobsModel->kill(Private::jobId(idx)); +} + +void Notifications::clear(ClearFlags flags) +{ + d->notificationsModel->clear(flags); + d->jobsModel->clear(flags); +} + +QVariant Notifications::data(const QModelIndex &index, int role) const +{ + return QSortFilterProxyModel::data(index, role); +} + +bool Notifications::setData(const QModelIndex &index, const QVariant &value, int role) +{ + return QSortFilterProxyModel::setData(index, value, role); +} + +bool Notifications::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + return QSortFilterProxyModel::filterAcceptsRow(source_row, source_parent); +} + +bool Notifications::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const +{ + return QSortFilterProxyModel::lessThan(source_left, source_right); +} + +int Notifications::rowCount(const QModelIndex &parent) const +{ + return QSortFilterProxyModel::rowCount(parent); +} + +QHash Notifications::roleNames() const +{ + return QHash { + {IdRole, QByteArrayLiteral("notificationId")}, // id is QML-reserved + {IsGroupRole, QByteArrayLiteral("isGroup")}, + {GroupChildrenCountRole, QByteArrayLiteral("groupChildrenCount")}, + {IsGroupExpandedRole, QByteArrayLiteral("isGroupExpanded")}, + {IsInGroupRole, QByteArrayLiteral("isInGroup")}, + {TypeRole, QByteArrayLiteral("type")}, + {CreatedRole, QByteArrayLiteral("created")}, + {UpdatedRole, QByteArrayLiteral("updated")}, + {SummaryRole, QByteArrayLiteral("summary")}, + {BodyRole, QByteArrayLiteral("body")}, + {IconNameRole, QByteArrayLiteral("iconName")}, + {ImageRole, QByteArrayLiteral("image")}, + {DesktopEntryRole, QByteArrayLiteral("desktopEntry")}, + {NotifyRcNameRole, QByteArrayLiteral("notifyRcName")}, + + {ApplicationNameRole, QByteArrayLiteral("applicationName")}, + {ApplicationIconNameRole, QByteArrayLiteral("applicationIconName")}, + {DeviceNameRole, QByteArrayLiteral("deviceName")}, + + {ActionNamesRole, QByteArrayLiteral("actionNames")}, + {ActionLabelsRole, QByteArrayLiteral("actionLabels")}, + {HasDefaultActionRole, QByteArrayLiteral("hasDefaultAction")}, + {DefaultActionLabelRole, QByteArrayLiteral("defaultActionLabel")}, + + {UrlsRole, QByteArrayLiteral("urls")}, + + {UrgencyRole, QByteArrayLiteral("urgency")}, + {TimeoutRole, QByteArrayLiteral("timeout")}, + + {ClosableRole, QByteArrayLiteral("closable")}, + {ConfigurableRole, QByteArrayLiteral("configurable")}, + {ConfigureActionLabelRole, QByteArrayLiteral("configureActionLabel")}, + + {JobStateRole, QByteArrayLiteral("jobState")}, + {PercentageRole, QByteArrayLiteral("percentage")}, + {ErrorRole, QByteArrayLiteral("error")}, + {ErrorTextRole, QByteArrayLiteral("errorText")}, + {SuspendableRole, QByteArrayLiteral("suspendable")}, + {KillableRole, QByteArrayLiteral("killable")}, + {JobDetailsRole, QByteArrayLiteral("jobDetails")}, + + {ExpiredRole, QByteArrayLiteral("expired")}, + {DismissedRole, QByteArrayLiteral("dismissed")} + }; +} diff --git a/libnotificationmanager/notificationsmodel.h b/libnotificationmanager/notificationsmodel.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationsmodel.h @@ -0,0 +1,72 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include "notifications.h" + +namespace NotificationManager +{ + +class NotificationsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + ~NotificationsModel() override; + + using Ptr = QSharedPointer; + static Ptr createNotificationsModel(); + + QDateTime lastRead() const; + void setLastRead(const QDateTime &lastRead); + + QVariant data(const QModelIndex &index, int role) const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + void expire(uint notificationId); + void close(uint notificationId); + void configure(uint notificationId); + void configure(const QString &desktopEntry, const QString ¬ifyRcName, const QString &eventId); + void invokeDefaultAction(uint notificationId); + void invokeAction(uint notificationId, const QString &actionName); + + void startTimeout(uint notificationId); + void stopTimeout(uint notificationId); + + void clear(Notifications::ClearFlags flags); + +signals: + void lastReadChanged(); + +private: + class Private; + QScopedPointer d; + + NotificationsModel(); + Q_DISABLE_COPY(NotificationsModel) + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/notificationsmodel.cpp b/libnotificationmanager/notificationsmodel.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationsmodel.cpp @@ -0,0 +1,470 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "notificationsmodel.h" + +#include "debug.h" + +#include "server.h" + +#include "notifications.h" + +#include "notification.h" +#include "notification_p.h" + +#include +#include +#include + +#include + +#include +#include + +using namespace NotificationManager; + +class Q_DECL_HIDDEN NotificationsModel::Private +{ +public: + explicit Private(NotificationsModel *q); + ~Private(); + + void onNotificationAdded(const Notification ¬ification); + void onNotificationReplaced(uint replacedId, const Notification ¬ification); + void onNotificationRemoved(uint notificationId, Server::CloseReason reason); + + void setupNotificationTimeout(const Notification ¬ification); + + int rowOfNotification(uint id) const; + + NotificationsModel *q; + + QVector notifications; + // Fallback timeout to ensure all notifications expire eventually + // otherwise when it isn't shown to the user and doesn't expire + // an app might wait indefinitely for the notification to do so + QHash notificationTimeouts; + + QDateTime lastRead; + +}; + +NotificationsModel::Private::Private(NotificationsModel *q) + : q(q) + , lastRead(QDateTime::currentDateTimeUtc()) +{ + +} + +NotificationsModel::Private::~Private() +{ + qDeleteAll(notificationTimeouts); + notificationTimeouts.clear(); +} + +void NotificationsModel::Private::onNotificationAdded(const Notification ¬ification) +{ + // If we get the same notification in succession, just compress them into one + if (!notifications.isEmpty()) { + const Notification &lastNotification = notifications.constLast(); + if (lastNotification.applicationName() == notification.applicationName() + && lastNotification.summary() == notification.summary() + && lastNotification.body() == notification.body() + && lastNotification.desktopEntry() == notification.desktopEntry() + && lastNotification.applicationName() == notification.applicationName()) { + onNotificationReplaced(lastNotification.id(), notification); + return; + } + } + + setupNotificationTimeout(notification); + + q->beginInsertRows(QModelIndex(), notifications.count(), notifications.count()); + notifications.append(notification); + q->endInsertRows(); +} + +void NotificationsModel::Private::onNotificationReplaced(uint replacedId, const Notification ¬ification) +{ + const int row = rowOfNotification(replacedId); + + if (row == -1) { + return; + } + + Q_ASSERT(notifications[row].id() == notification.id()); + setupNotificationTimeout(notification); + + notifications[row] = notification; + const QModelIndex idx = q->index(row, 0); + emit q->dataChanged(idx, idx); +} + +void NotificationsModel::Private::onNotificationRemoved(uint removedId, Server::CloseReason reason) +{ + const int row = rowOfNotification(removedId); + if (row == -1) { + return; + } + + q->stopTimeout(removedId); + + // When a notification expired, keep it around in the history and mark it as such + if (reason == Server::CloseReason::Expired) { + const QModelIndex idx = q->index(row, 0); + + Notification ¬ification = notifications[row]; + notification.setExpired(true); + + // Since the notification is "closed" it cannot have any actions + // unless it is "resident" which we don't support + notification.setActions(QStringList()); + + emit q->dataChanged(idx, idx, { + Notifications::ExpiredRole, + // TODO only emit those if actually changed? + Notifications::ActionNamesRole, + Notifications::ActionLabelsRole, + Notifications::HasDefaultActionRole, + Notifications::DefaultActionLabelRole, + Notifications::ConfigurableRole + }); + + return; + } + + // Otherwise if explicitly closed by either user or app, remove it + + q->beginRemoveRows(QModelIndex(), row, row); + notifications.removeAt(row); + q->endRemoveRows(); +} + +void NotificationsModel::Private::setupNotificationTimeout(const Notification ¬ification) +{ + if (notification.timeout() == 0) { + // In case it got replaced by a persistent notification + q->stopTimeout(notification.id()); + return; + } + + QTimer *timer = notificationTimeouts.value(notification.id()); + if (!timer) { + timer = new QTimer(); + timer->setSingleShot(true); + + connect(timer, &QTimer::timeout, q, [this, timer] { + const uint id = timer->property("notificationId").toUInt(); + q->expire(id); + }); + notificationTimeouts.insert(notification.id(), timer); + } + + timer->stop(); + timer->setProperty("notificationId", notification.id()); + timer->setInterval(60000 /*1min*/ + (notification.timeout() == -1 ? 120000 /*2min, max configurable default timeout*/ : notification.timeout())); + timer->start(); +} + +int NotificationsModel::Private::rowOfNotification(uint id) const +{ + auto it = std::find_if(notifications.constBegin(), notifications.constEnd(), [id](const Notification &item) { + return item.id() == id; + }); + + if (it == notifications.constEnd()) { + return -1; + } + + return std::distance(notifications.constBegin(), it); +} + +NotificationsModel::NotificationsModel() + : QAbstractListModel(nullptr) + , d(new Private(this)) +{ + connect(&Server::self(), &Server::notificationAdded, this, [this](const Notification ¬ification) { + d->onNotificationAdded(notification); + }); + connect(&Server::self(), &Server::notificationReplaced, this, [this](uint replacedId, const Notification ¬ification) { + d->onNotificationReplaced(replacedId, notification); + }); + connect(&Server::self(), &Server::notificationRemoved, this, [this](uint removedId, Server::CloseReason reason) { + d->onNotificationRemoved(removedId, reason); + }); + + Server::self().init(); +} + +NotificationsModel::~NotificationsModel() = default; + +NotificationsModel::Ptr NotificationsModel::createNotificationsModel() +{ + static QWeakPointer s_instance; + if (!s_instance) { + QSharedPointer ptr(new NotificationsModel()); + s_instance = ptr.toWeakRef(); + return ptr; + } + return s_instance.toStrongRef(); +} + +QDateTime NotificationsModel::lastRead() const +{ + return d->lastRead; +} + +void NotificationsModel::setLastRead(const QDateTime &lastRead) +{ + if (d->lastRead != lastRead) { + d->lastRead = lastRead; + emit lastReadChanged(); + } +} + +QVariant NotificationsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= d->notifications.count()) { + return QVariant(); + } + + const Notification ¬ification = d->notifications.at(index.row()); + + switch (role) { + case Notifications::IdRole: return notification.id(); + case Notifications::TypeRole: return Notifications::NotificationType; + + case Notifications::CreatedRole: + if (notification.created().isValid()) { + return notification.created(); + } + break; + case Notifications::UpdatedRole: + if (notification.updated().isValid()) { + return notification.updated(); + } + break; + case Notifications::SummaryRole: return notification.summary(); + case Notifications::BodyRole: return notification.body(); + case Notifications::IconNameRole: return notification.iconName(); + case Notifications::ImageRole: + if (!notification.image().isNull()) { + return notification.image(); + } + break; + case Notifications::DesktopEntryRole: return notification.desktopEntry(); + case Notifications::NotifyRcNameRole: return notification.notifyRcName(); + + case Notifications::ApplicationNameRole: return notification.applicationName(); + case Notifications::ApplicationIconNameRole: return notification.applicationIconName(); + case Notifications::DeviceNameRole: return notification.deviceName(); + + case Notifications::ActionNamesRole: return notification.actionNames(); + case Notifications::ActionLabelsRole: return notification.actionLabels(); + case Notifications::HasDefaultActionRole: return notification.hasDefaultAction(); + case Notifications::DefaultActionLabelRole: return notification.defaultActionLabel(); + + case Notifications::UrlsRole: return QVariant::fromValue(notification.urls()); + + case Notifications::UrgencyRole: return static_cast(notification.urgency()); + + case Notifications::TimeoutRole: return notification.timeout(); + + case Notifications::ClosableRole: return true; + case Notifications::ConfigurableRole: return notification.configurable(); + case Notifications::ConfigureActionLabelRole: return notification.configureActionLabel(); + + case Notifications::ExpiredRole: return notification.expired(); + } + + return QVariant(); +} + +int NotificationsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return d->notifications.count(); +} + +void NotificationsModel::expire(uint notificationId) +{ + if (d->rowOfNotification(notificationId) > -1) { + Server::self().closeNotification(notificationId, Server::CloseReason::Expired); + } +} + +void NotificationsModel::close(uint notificationId) +{ + if (d->rowOfNotification(notificationId) > -1) { + Server::self().closeNotification(notificationId, Server::CloseReason::DismissedByUser); + } +} + +void NotificationsModel::configure(uint notificationId) +{ + const int row = d->rowOfNotification(notificationId); + if (row == -1) { + return; + } + + const Notification ¬ification = d->notifications.at(row); + + if (notification.d->hasConfigureAction) { + Server::self().invokeAction(notificationId, QStringLiteral("settings")); // FIXME make a static Notification::configureActionName() or something + return; + } + + if (!notification.desktopEntry().isEmpty() || !notification.notifyRcName().isEmpty()) { + configure(notification.desktopEntry(), notification.notifyRcName(), notification.eventId()); + return; + } + + qCWarning(NOTIFICATIONMANAGER) << "Trying to configure notification" << notificationId << "which isn't configurable"; +} + +void NotificationsModel::configure(const QString &desktopEntry, const QString ¬ifyRcName, const QString &eventId) +{ + // TODO would be nice to just have a signal but since NotificationsModel is shared, + // if we connect to this from Notifications you would get a signal in every instance + // and potentialy open the config dialog multiple times. + + QStringList args; + if (!desktopEntry.isEmpty()) { + args.append(QStringLiteral("--desktop-entry")); + args.append(desktopEntry); + } + if (!notifyRcName.isEmpty()) { + args.append(QStringLiteral("--notifyrc")); + args.append(notifyRcName); + } + if (!eventId.isEmpty()) { + args.append(QStringLiteral("--event-id")); + args.append(eventId); + } + + QProcess::startDetached(QStringLiteral("kcmshell5"), { + QStringLiteral("notifications"), + QStringLiteral("--args"), + KShell::joinArgs(args) + }); +} + +void NotificationsModel::invokeDefaultAction(uint notificationId) +{ + const int row = d->rowOfNotification(notificationId); + if (row == -1) { + return; + } + + const Notification ¬ification = d->notifications.at(row); + if (!notification.hasDefaultAction()) { + qCWarning(NOTIFICATIONMANAGER) << "Trying to invoke default action on notification" << notificationId << "which doesn't have one"; + return; + } + + Server::self().invokeAction(notificationId, QStringLiteral("default")); // FIXME make a static Notification::defaultActionName() or something +} + +void NotificationsModel::invokeAction(uint notificationId, const QString &actionName) +{ + const int row = d->rowOfNotification(notificationId); + if (row == -1) { + return; + } + + const Notification ¬ification = d->notifications.at(row); + if (!notification.actionNames().contains(actionName)) { + qCWarning(NOTIFICATIONMANAGER) << "Trying to invoke action" << actionName << "on notification" << notificationId << "which it doesn't have"; + return; + } + + Server::self().invokeAction(notificationId, actionName); +} + +void NotificationsModel::startTimeout(uint notificationId) +{ + const int row = d->rowOfNotification(notificationId); + if (row == -1) { + return; + } + + const Notification ¬ification = d->notifications.at(row); + + if (!notification.timeout() || notification.expired()) { + return; + } + + d->setupNotificationTimeout(notification); +} + +void NotificationsModel::stopTimeout(uint notificationId) +{ + delete d->notificationTimeouts.take(notificationId); +} + +void NotificationsModel::clear(Notifications::ClearFlags flags) +{ + if (d->notifications.isEmpty()) { + return; + } + + // Tries to remove a contiguous group if possible as the likely case is + // you have n unread notifications at the end of the list, we don't want to + // remove and signal each item individually + QVector> clearQueue; + + QPair clearRange{-1, -1}; + + for (int i = d->notifications.count() - 1; i >= 0; --i) { + const Notification ¬ification = d->notifications.at(i); + + bool clear = (flags.testFlag(Notifications::ClearExpired) && notification.expired()); + + if (clear) { + if (clearRange.second == -1) { + clearRange.second = i; + } + clearRange.first = i; + } else { + if (clearRange.first != -1) { + clearQueue.append(clearRange); + clearRange.first = -1; + clearRange.second = -1; + } + } + } + + if (clearRange.first != -1) { + clearQueue.append(clearRange); + clearRange.first = -1; + clearRange.second = -1; + } + + for (const auto &range : clearQueue) { + beginRemoveRows(QModelIndex(), range.first, range.second); + for (int i = range.second; i >= range.first; --i) { + d->notifications.removeAt(i); + } + endRemoveRows(); + } +} diff --git a/libnotificationmanager/notificationsortproxymodel.cpp b/libnotificationmanager/notificationsortproxymodel.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationsortproxymodel.cpp @@ -0,0 +1,117 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + + +#include "notificationsortproxymodel_p.h" + +#include + +#include "notifications.h" + +using namespace NotificationManager; + +NotificationSortProxyModel::NotificationSortProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setRecursiveFilteringEnabled(true); + sort(0); +} + +NotificationSortProxyModel::~NotificationSortProxyModel() = default; + +Notifications::SortMode NotificationSortProxyModel::sortMode() const +{ + return m_sortMode; +} + +void NotificationSortProxyModel::setSortMode(Notifications::SortMode sortMode) +{ + if (m_sortMode != sortMode) { + m_sortMode = sortMode; + invalidate(); + emit sortModeChanged(); + } +} + +int sortScore(const QModelIndex &idx) +{ + const auto urgency = idx.data(Notifications::UrgencyRole).toInt(); + if (urgency == Notifications::CriticalUrgency) { + return 3; + } + + const int type = idx.data(Notifications::TypeRole).toInt(); + if (type == Notifications::JobType) { + const int jobState = idx.data(Notifications::JobStateRole).toInt(); + // Treat finished jobs as normal notifications but running jobs more important + if (jobState == Notifications::JobStateStopped) { + return 1; + } else { + return 2; + } + } + + if (urgency == Notifications::NormalUrgency) { + return 1; + } + + if (urgency == Notifications::LowUrgency) { + return 0; + } + + return -1; +} + +bool NotificationSortProxyModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const +{ + // Sort order is (descending): + // - Critical notifications + // - Jobs + // - Normal notifications + // - Low urgency notifications + // Within each group it's descending by created or last modified + + int scoreLeft = 0; + int scoreRight = 0; + + if (m_sortMode == Notifications::SortByTypeAndUrgency) { + scoreLeft = sortScore(source_left); + Q_ASSERT(scoreLeft >= 0); + scoreRight = sortScore(source_right); + Q_ASSERT(scoreRight >= 0); + } + + if (scoreLeft == scoreRight) { + QDateTime timeLeft = source_left.data(Notifications::UpdatedRole).toDateTime(); + if (!timeLeft.isValid()) { + timeLeft = source_left.data(Notifications::CreatedRole).toDateTime(); + } + + QDateTime timeRight = source_right.data(Notifications::UpdatedRole).toDateTime(); + if (!timeRight.isValid()) { + timeRight = source_right.data(Notifications::CreatedRole).toDateTime(); + } + + // sorts descending by time (newest first) + return timeLeft > timeRight; + } + + return scoreLeft > scoreRight; +} diff --git a/libnotificationmanager/notificationsortproxymodel_p.h b/libnotificationmanager/notificationsortproxymodel_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/notificationsortproxymodel_p.h @@ -0,0 +1,53 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + + +#pragma once + +#include + +#include "notifications.h" + +namespace NotificationManager +{ + +class NotificationSortProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + explicit NotificationSortProxyModel(QObject *parent = nullptr); + ~NotificationSortProxyModel() override; + + Notifications::SortMode sortMode() const; + void setSortMode(Notifications::SortMode); + +signals: + void sortModeChanged(); + +protected: + bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; + +private: + Notifications::SortMode m_sortMode = Notifications::SortByDate; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/plasmanotifyrc b/libnotificationmanager/plasmanotifyrc new file mode 100644 --- /dev/null +++ b/libnotificationmanager/plasmanotifyrc @@ -0,0 +1,2 @@ +[Services][spectacle] +ShowPopupsInDndMode=true diff --git a/libnotificationmanager/server.h b/libnotificationmanager/server.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/server.h @@ -0,0 +1,157 @@ +/* + * Copyright 2018 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include "notificationmanager_export.h" + +namespace NotificationManager +{ + +class Notification; + +class ServerPrivate; + +/** + * @short A notification DBus server + * + * @author Kai Uwe Broulik + **/ +class NOTIFICATIONMANAGER_EXPORT Server : public QObject +{ + Q_OBJECT + +public: + ~Server() override; + + /** + * The reason a notification was closed + */ + enum class CloseReason { + Expired = 1, ///< The notification timed out + DismissedByUser = 2, ///< The user explicitly closed or acknowledged the notification + Revoked = 3 ///< The notification was revoked by the issuing app because it is no longer relevant + }; + Q_ENUM(CloseReason) + + static Server &self(); + + /** + * Registers the Notification Service on DBus. + * + * @return true if it succeeded, false otherwise. + */ + bool init(); + + /** + * Whether the notification service could be registered + */ + bool isValid() const; + + /** + * Whether an application requested to inhibit notifications. + */ + bool inhibited() const; + + // should we return a struct or pair or something? + QStringList inhibitionApplications() const; + QStringList inhibitionReasons() const; + + /** + * Remove all inhibitions. + * + * @note The applications are not explicitly informed about this. + */ + void clearInhibitions(); + + /** + * Sends a notification closed event + * + * @param id The notification ID + * @reason The reason why it was closed + */ + void closeNotification(uint id, CloseReason reason); + /** + * Sends an action invocation request + * + * @param id The notification ID + * @param actionName The name of the action, e.g. "Action 1", or "default" + */ + void invokeAction(uint id, const QString &actionName); + + /** + * Adds a notification + * + * @note The notification isn't actually broadcast + * but just emitted locally. + * + * @return the ID of the notification + */ + uint add(const Notification ¬ification); + +Q_SIGNALS: + /** + * Emitted when a notification was added. + * This is emitted regardless of any filtering rules or user settings. + * @param notification The notification + */ + void notificationAdded(const Notification ¬ification); + /** + * Emitted when a notification is supposed to be updated + * This is emitted regardless of any filtering rules or user settings. + * @param replacedId The ID of the notification it replaces + * @param notification The new notification to use instead + */ + void notificationReplaced(uint replacedId, const Notification ¬ification); + /** + * Emitted when a notification got removed (closed) + * @param id The notification ID + * @param reason The reason why it was closed + */ + void notificationRemoved(uint id, CloseReason reason); + + /** + * Emitted when inhibitions have been changed. Becomes true + * as soon as there is one inhibition and becomes false again + * when all inhibitions have been lifted. + */ + void inhibitedChanged(bool inhibited); + + /** + * Emitted when the list of applications holding a notification + * inhibition changes. + * Normally you would only want to listen do @c inhibitedChanged + */ + void inhibitionApplicationsChanged(); + +private: + explicit Server(QObject *parent = nullptr); + Q_DISABLE_COPY(Server) + // FIXME we also need to disable move and other stuff? + + class Private; + QScopedPointer d; +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/server.cpp b/libnotificationmanager/server.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/server.cpp @@ -0,0 +1,111 @@ +/* + * Copyright 2018 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "server.h" +#include "server_p.h" + +#include "notification.h" +#include "notification_p.h" + +#include "debug.h" + +#include + +#include + +using namespace NotificationManager; + +Server::Server(QObject *parent) + : QObject(parent) + , d(new ServerPrivate(this)) +{ + connect(d.data(), &ServerPrivate::inhibitedChanged, this, [this] { + emit inhibitedChanged(inhibited()); + }); + connect(d.data(), &ServerPrivate::inhibitionAdded, this, &Server::inhibitionApplicationsChanged); + connect(d.data(), &ServerPrivate::inhibitionRemoved, this, &Server::inhibitionApplicationsChanged); +} + +Server::~Server() = default; + +Server &Server::self() +{ + static Server s_self; + return s_self; +} + +bool Server::init() +{ + return d->init(); +} + +bool Server::isValid() const +{ + return d->m_valid; +} + +void Server::closeNotification(uint notificationId, CloseReason reason) +{ + emit notificationRemoved(notificationId, reason); + + emit d->NotificationClosed(notificationId, static_cast(reason)); // tell on DBus +} + +void Server::invokeAction(uint notificationId, const QString &actionName) +{ + emit d->ActionInvoked(notificationId, actionName); +} + +uint Server::add(const Notification ¬ification) +{ + return d->add(notification); +} + +bool Server::inhibited() const +{ + return d->inhibited(); +} + +QStringList Server::inhibitionApplications() const +{ + QStringList applications; + const auto inhibitions = d->inhibitions(); + applications.reserve(inhibitions.count()); + for (const auto &inhibition : inhibitions) { + applications.append(!inhibition.applicationName.isEmpty() ? inhibition.applicationName : inhibition.desktopEntry); + } + return applications; +} + +QStringList Server::inhibitionReasons() const +{ + QStringList reasons; + const auto inhibitions = d->inhibitions(); + reasons.reserve(inhibitions.count()); + for (const auto &inhibition : inhibitions) { + reasons.append(inhibition.reason); + } + return reasons; +} + +void Server::clearInhibitions() +{ + d->clearInhibitions(); +} diff --git a/libnotificationmanager/server_p.h b/libnotificationmanager/server_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/server_p.h @@ -0,0 +1,102 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include +#include + +class QDBusServiceWatcher; + +struct Inhibition +{ + QString desktopEntry; + QString applicationName; + //QString applicationIconName; + QString reason; + QVariantMap hints; +}; + +namespace NotificationManager +{ + +class Notification; + +class Q_DECL_HIDDEN ServerPrivate : public QObject, protected QDBusContext +{ + Q_OBJECT + + // DBus + // Inhibitions + Q_PROPERTY(bool Inhibited READ inhibited) + +public: + ServerPrivate(QObject *parent); + ~ServerPrivate() override; + + // DBus + uint Notify(const QString &app_name, uint replaces_id, const QString &app_icon, + const QString &summary, const QString &body, const QStringList &actions, + const QVariantMap &hints, int timeout); + void CloseNotification(uint id); + QStringList GetCapabilities() const; + QString GetServerInformation(QString &vendor, QString &version, QString &specVersion) const; + + // Inhibitions + uint Inhibit(const QString &desktop_entry, + const QString &reason, + const QVariantMap &hints); + void UnInhibit(uint cookie); + //QList ListInhibitors() const; + bool inhibited() const; // property getter + +Q_SIGNALS: + // DBus + void NotificationClosed(uint id, uint reason); + void ActionInvoked(uint id, const QString &actionKey); + + void inhibitedChanged(); + void inhibitionAdded(); + void inhibitionRemoved(); + +public: // stuff used by public class + bool init(); + uint add(const Notification ¬ification); + + QList inhibitions() const; + void clearInhibitions(); + + bool m_valid = false; + uint m_highestNotificationId = 0; + +private slots: + void onBroadcastNotification(const QMap &properties); + +private: + void onServiceUnregistered(const QString &serviceName); + + QDBusServiceWatcher *m_inhibitionWatcher = nullptr; + uint m_highestInhibitionCookie = 0; + QHash m_inhibitions; + QHash m_inhibitionServices; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/server_p.cpp b/libnotificationmanager/server_p.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/server_p.cpp @@ -0,0 +1,350 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "server_p.h" + +#include "debug.h" + +#include "notificationsadaptor.h" + +#include "notification.h" +#include "notification_p.h" + +#include "server.h" + +#include +#include + +#include +#include +#include +#include + +#include +#include + +using namespace NotificationManager; + +ServerPrivate::ServerPrivate(QObject *parent) + : QObject(parent) +{ + +} + +ServerPrivate::~ServerPrivate() = default; + +bool ServerPrivate::init() +{ + if (m_valid) { + return true; + } + + new NotificationsAdaptor(this); + + if (!QDBusConnection::sessionBus().registerObject(QStringLiteral("/org/freedesktop/Notifications"), this)) { + qCWarning(NOTIFICATIONMANAGER) << "Failed to register Notification DBus object"; + return false; + } + + if (!QDBusConnection::sessionBus().registerService(QStringLiteral("org.freedesktop.Notifications"))) { + qCWarning(NOTIFICATIONMANAGER) << "Failed to register Notification service on DBus"; + return false; + } + + m_inhibitionWatcher = new QDBusServiceWatcher(this); + m_inhibitionWatcher->setConnection(QDBusConnection::sessionBus()); + m_inhibitionWatcher->setWatchMode(QDBusServiceWatcher::WatchForUnregistration); + connect(m_inhibitionWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &ServerPrivate::onServiceUnregistered); + + connect(this, &ServerPrivate::inhibitedChanged, this, [this] { + // emit DBus change signal... + QDBusMessage signal = QDBusMessage::createSignal( + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.DBus.Properties"), + QStringLiteral("PropertiesChanged") + ); + + signal.setArguments({ + QStringLiteral("org.freedesktop.Notifications"), + QVariantMap{ // updated + {QStringLiteral("Inhibited"), inhibited()}, + }, + QStringList() // invalidated + }); + + QDBusConnection::sessionBus().send(signal); + }); + + qCDebug(NOTIFICATIONMANAGER) << "Registered Notification service on DBus"; + + KConfigGroup config(KSharedConfig::openConfig(), QStringLiteral("Notifications")); + const bool broadcastsEnabled = config.readEntry("ListenForBroadcasts", false); + + if (broadcastsEnabled) { + qCDebug(NOTIFICATIONMANAGER) << "Notification server is configured to listen for broadcasts"; + QDBusConnection::systemBus().connect({}, {}, QStringLiteral("org.kde.BroadcastNotifications"), + QStringLiteral("Notify"), this, SLOT(onBroadcastNotification(QMap))); + } + + m_valid = true; + return true; +} + +uint ServerPrivate::Notify(const QString &app_name, uint replaces_id, const QString &app_icon, + const QString &summary, const QString &body, const QStringList &actions, + const QVariantMap &hints, int timeout) +{ + const bool wasReplaced = replaces_id > 0; + int notificationId = 0; + if (wasReplaced) { + notificationId = replaces_id; + } else { + // TODO according to spec should wrap around once INT_MAX is exceeded + ++m_highestNotificationId; + notificationId = m_highestNotificationId; + } + + Notification notification(notificationId); + notification.setSummary(summary); + notification.setBody(body); + // we actually use that as notification icon (unless an image pixmap is provided in hints) + notification.setIconName(app_icon); + notification.setApplicationName(app_name); + + notification.setActions(actions); + + notification.setTimeout(timeout); + + // might override some of the things we set above (like application name) + notification.d->processHints(hints); + + // No application name? Try to figure out the process name using the sender's PID + if (notification.applicationName().isEmpty()) { + qCInfo(NOTIFICATIONMANAGER) << "Notification from service" << message().service() << "didn't contain any identification information, this is an application bug!"; + + QDBusReply pidReply = connection().interface()->servicePid(message().service()); + if (pidReply.isValid()) { + const auto pid = pidReply.value(); + + KSysGuard::Processes procs; + procs.updateOrAddProcess(pid); + + if (KSysGuard::Process *proc = procs.getProcess(pid)) { + qCDebug(NOTIFICATIONMANAGER) << "Resolved notification to be from PID" << pid << "which is" << proc->name(); + notification.setApplicationName(proc->name()); + } + } + } + + if (wasReplaced) { + notification.resetUpdated(); + emit static_cast(parent())->notificationReplaced(replaces_id, notification); + } else { + emit static_cast(parent())->notificationAdded(notification); + } + + return notificationId; +} + +void ServerPrivate::CloseNotification(uint id) +{ + // spec says "If the notification no longer exists, an empty D-BUS Error message is sent back." + static_cast(parent())->closeNotification(id, Server::CloseReason::Revoked); +} + +QStringList ServerPrivate::GetCapabilities() const +{ + // should this be configurable somehow so the UI can tell what it implements? + return QStringList{ + QStringLiteral("body"), + QStringLiteral("body-hyperlinks"), + QStringLiteral("body-markup"), + QStringLiteral("body-images"), + QStringLiteral("icon-static"), + QStringLiteral("actions"), + // should we support "persistence" where notification stays present with "resident" + // but that is basically an SNI isn't it? + + QStringLiteral("x-kde-urls"), + + QStringLiteral("inhibitions") + }; +} + +QString ServerPrivate::GetServerInformation(QString &vendor, QString &version, QString &specVersion) const +{ + vendor = QStringLiteral("KDE"); + version = QLatin1String(PROJECT_VERSION); + specVersion = QStringLiteral("1.2"); + return QStringLiteral("Plasma"); +} + +void ServerPrivate::onBroadcastNotification(const QMap &properties) +{ + qCDebug(NOTIFICATIONMANAGER) << "Received broadcast notification"; + + const auto currentUserId = KUserId::currentEffectiveUserId().nativeId(); + + // a QVariantList with ints arrives as QDBusArgument here, using a QStringList for simplicity + const QStringList &userIds = properties.value(QStringLiteral("uids")).toStringList(); + if (!userIds.isEmpty()) { + auto it = std::find_if(userIds.constBegin(), userIds.constEnd(), [currentUserId](const QVariant &id) { + bool ok; + auto uid = id.toString().toLongLong(&ok); + return ok && uid == currentUserId; + }); + + if (it == userIds.constEnd()) { + qCDebug(NOTIFICATIONMANAGER) << "It is not meant for us, ignoring"; + return; + } + } + + bool ok; + int timeout = properties.value(QStringLiteral("timeout")).toInt(&ok); + if (!ok) { + timeout = -1; // -1 = server default, 0 would be "persistent" + } + + Notify( + properties.value(QStringLiteral("appName")).toString(), + 0, // replaces_id + properties.value(QStringLiteral("appIcon")).toString(), + properties.value(QStringLiteral("summary")).toString(), + properties.value(QStringLiteral("body")).toString(), + {}, // no actions + properties.value(QStringLiteral("hints")).toMap(), + timeout + ); +} + +uint ServerPrivate::add(const Notification ¬ification) +{ + // TODO check if notification with ID already exists and signal update instead + if (notification.id() == 0) { + ++m_highestNotificationId; + notification.d->id = m_highestNotificationId; + + emit static_cast(parent())->notificationAdded(notification); + } else { + emit static_cast(parent())->notificationReplaced(notification.id(), notification); + } + + return notification.id(); +} + +uint ServerPrivate::Inhibit(const QString &desktop_entry, const QString &reason, const QVariantMap &hints) +{ + const QString dbusService = message().service(); + + qCDebug(NOTIFICATIONMANAGER) << "Request inhibit from service" << dbusService << "which is" << desktop_entry << "with reason" << reason; + + if (desktop_entry.isEmpty()) { + // TODO return error + return 0; + } + + KService::Ptr service = KService::serviceByDesktopName(desktop_entry); + QString applicationName; + if (service) { // should we check for this and error if it didn't find a service? + applicationName = service->name(); + } + + m_inhibitionWatcher->addWatchedService(dbusService); + + ++m_highestInhibitionCookie; + + m_inhibitions.insert(m_highestInhibitionCookie, { + desktop_entry, + applicationName, + reason, + hints + }); + + m_inhibitionServices.insert(m_highestInhibitionCookie, dbusService); + + emit inhibitedChanged(); + emit inhibitionAdded(); + + return m_highestInhibitionCookie; +} + +void ServerPrivate::onServiceUnregistered(const QString &serviceName) +{ + qCDebug(NOTIFICATIONMANAGER) << "Inhibition service unregistered" << serviceName; + + const uint cookie = m_inhibitionServices.key(serviceName); + if (!cookie) { + qCInfo(NOTIFICATIONMANAGER) << "Unknown inhibition service unregistered" << serviceName; + return; + } + + // We do lookups in there again... + UnInhibit(cookie); +} + +void ServerPrivate::UnInhibit(uint cookie) +{ + qCDebug(NOTIFICATIONMANAGER) << "Request release inhibition for cookie" << cookie; + + const QString service = m_inhibitionServices.value(cookie); + if (service.isEmpty()) { + qCInfo(NOTIFICATIONMANAGER) << "Requested to release inhibition with cookie" << cookie << "that doesn't exist"; + // TODO if called from dbus raise error + return; + } + + m_inhibitionWatcher->removeWatchedService(service); + m_inhibitions.remove(cookie); + m_inhibitionServices.remove(cookie); + + if (m_inhibitions.isEmpty()) { + emit inhibitedChanged(); + emit inhibitionRemoved(); + } +} + +/*QList ServerPrivate::ListInhibitors() const +{ + return m_inhibitions.values(); +}*/ + +QList ServerPrivate::inhibitions() const +{ + return m_inhibitions.values(); +} + +bool ServerPrivate::inhibited() const +{ + return !m_inhibitions.isEmpty(); +} + +void ServerPrivate::clearInhibitions() +{ + if (m_inhibitions.isEmpty()) { + return; + } + + m_inhibitionWatcher->setWatchedServices(QStringList()); // remove all watches + m_inhibitionServices.clear(); + m_inhibitions.clear(); + emit inhibitedChanged(); + emit inhibitionRemoved(); +} diff --git a/libnotificationmanager/settings.h b/libnotificationmanager/settings.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/settings.h @@ -0,0 +1,336 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#pragma once + +#include + +#include + +#include +#include +#include + +#include "notificationmanager_export.h" + +namespace NotificationManager +{ + +/** + * @short Notification settings and state + * + * This class encapsulates all global settings related to notifications + * as well as do not disturb mode and other state. + * + * This class can be used by applications to alter their behavior + * depending on user's notification preferences. + * + * @author Kai Uwe Broulik + **/ +class NOTIFICATIONMANAGER_EXPORT Settings : public QObject +{ + Q_OBJECT + + /** + * Whether to show critical notification popups in do not disturb mode. + */ + Q_PROPERTY(bool criticalPopupsInDoNotDisturbMode READ criticalPopupsInDoNotDisturbMode WRITE setCriticalPopupsInDoNotDisturbMode NOTIFY settingsChanged) + /** + * Whether to keep critical notifications always on top. + */ + Q_PROPERTY(bool keepCriticalAlwaysOnTop READ keepCriticalAlwaysOnTop WRITE setKeepCriticalAlwaysOnTop NOTIFY settingsChanged) + /** + * Whether to show popups for low priority notifications. + */ + Q_PROPERTY(bool lowPriorityPopups READ lowPriorityPopups WRITE setLowPriorityPopups NOTIFY settingsChanged) + /** + * Whether to add low priority notifications to the history. + */ + Q_PROPERTY(bool lowPriorityHistory READ lowPriorityHistory WRITE setLowPriorityHistory NOTIFY settingsChanged) + + /** + * The notification popup position on screen. + * NearWidget means they should be positioned closely to where the plasmoid is located on screen. + */ + Q_PROPERTY(PopupPosition popupPosition READ popupPosition WRITE setPopupPosition NOTIFY settingsChanged) + + /** + * The default timeout for notification popups that do not have an explicit timeout set, + * in milliseconds. Default is 5000ms (5 seconds). + */ + Q_PROPERTY(int popupTimeout READ popupTimeout WRITE setPopupTimeout RESET resetPopupTimeout NOTIFY settingsChanged) + + /** + * Whether to show application jobs in task manager + */ + Q_PROPERTY(bool jobsInTaskManager READ jobsInTaskManager WRITE setJobsInTaskManager /*RESET resetJobsInTaskManager*/ NOTIFY settingsChanged) + /** + * Whether to show application jobs as notifications + */ + Q_PROPERTY(bool jobsInNotifications READ jobsInNotifications WRITE setJobsInNotifications /*RESET resetJobsPopup*/ NOTIFY settingsChanged) + /** + * Whether application jobs stay visible for the whole duration of the job + */ + Q_PROPERTY(bool permanentJobPopups READ permanentJobPopups WRITE setPermanentJobPopups /*RESET resetAutoHideJobsPopup*/ NOTIFY settingsChanged) + + /** + * Whether to show notification badges (numbers in circles) in task manager + */ + Q_PROPERTY(bool badgesInTaskManager READ badgesInTaskManager WRITE setBadgesInTaskManager NOTIFY settingsChanged) + + /** + * A list of desktop entries of applications that have been seen sending a notification. + */ + Q_PROPERTY(QStringList knownApplications READ knownApplications NOTIFY knownApplicationsChanged) + + /** + * A list of desktop entries of applications for which no popups should be shown. + */ + Q_PROPERTY(QStringList popupBlacklistedApplications READ popupBlacklistedApplications NOTIFY settingsChanged) + /** + * A list of notifyrc names of services for which no popups should be shown. + */ + Q_PROPERTY(QStringList popupBlacklistedServices READ popupBlacklistedServices NOTIFY settingsChanged) + + /** + * A list of desktop entries of applications for which a popup should be shown even in do not disturb mode. + */ + Q_PROPERTY(QStringList doNotDisturbPopupWhitelistedApplications + READ doNotDisturbPopupWhitelistedApplications + NOTIFY settingsChanged) + /** + * A list of notifyrc names of services for which a popup should be shown even in do not disturb mode. + */ + Q_PROPERTY(QStringList doNotDisturbPopupWhitelistedServices + READ doNotDisturbPopupWhitelistedServices + NOTIFY settingsChanged) + + /** + * A list of desktop entries of applications which shouldn't be shown in the history. + */ + Q_PROPERTY(QStringList historyBlacklistedApplications READ historyBlacklistedApplications NOTIFY settingsChanged) + /** + * A list of notifyrc names of services which shouldn't be shown in the history. + */ + Q_PROPERTY(QStringList historyBlacklistedServices READ historyBlacklistedServices NOTIFY settingsChanged) + + /** + * The date until which do not disturb mode is enabled. + * + * When invalid or in the past, do not disturb mode should be considered disabled. + * Do not disturb mode is considered active when this property points to a date + * in the future OR notificationsInhibitedByApplication is true. + */ + Q_PROPERTY(QDateTime notificationsInhibitedUntil + READ notificationsInhibitedUntil + WRITE setNotificationsInhibitedUntil + RESET resetNotificationsInhibitedUntil + NOTIFY settingsChanged) + + /** + * Whether an application currently requested do not disturb mode. + * + * Do not disturb mode is considered active when this property is true OR + * notificationsInhibitedUntil points to a date in the future. + * + * @sa revokeApplicationInhibitions + */ + Q_PROPERTY(bool notificationsInhibitedByApplication + READ notificationsInhibitedByApplication + NOTIFY notificationsInhibitedByApplicationChanged) + + Q_PROPERTY(QStringList notificationInhibitionApplications + READ notificationInhibitionApplications + NOTIFY notificationInhibitionApplicationsChanged) + + Q_PROPERTY(QStringList notificationInhibitionReasons + READ notificationInhibitionReasons + NOTIFY notificationInhibitionApplicationsChanged) + + /** + * Whether notification sounds should be disabled + * + * This does not reflect the actual mute state of the Notification Sounds + * but only remembers what value was assigned to this property. + * + * When @c true the notification sound stream is muted. + * When @c false the notification sound stream is unmuted only if it was + * previously muted by having set this property @c true. This is to avoid + * unmuting notification sounds if the user themselves have already muted them. + */ + Q_PROPERTY(bool notificationSoundsInhibited + WRITE setNotificationSoundsInhibited + NOTIFY NotificationSoundsInhibitedChanged) + + /** + * Whether the notification sound stream is actually muted + */ + Q_PROPERTY(bool notificationSoundsMuted + READ notificationSoundsMuted + WRITE setNotificationSoundsMuted + NOTIFY notificationSoundsMutedChanged) + + /** + * Whether to update the properties immediately when they are changed on disk + * + * This can be undesirable for a settings dialog where outside changes + * should not suddenly cause the UI to change. + * + * Default is true. + */ + Q_PROPERTY(bool live READ live WRITE setLive NOTIFY liveChanged) + + /** + * Whether the settings have changed and need to be saved + * + * @sa save() + */ + Q_PROPERTY(bool dirty READ dirty NOTIFY dirtyChanged) + +public: + explicit Settings(QObject *parent = nullptr); + Settings(const KSharedConfig::Ptr &config, QObject *parent = nullptr); + ~Settings() override; + + enum PopupPosition { + NearWidget = 0, // TODO better name? CloseToWidget? AtWidget? AroundWidget? + TopLeft, + TopCenter, + TopRight, + BottomLeft, + BottomCenter, + BottomRight + }; + Q_ENUM(PopupPosition) + + enum NotificationBehavior { + ShowPopups = 1 << 1, + ShowPopupsInDoNotDisturbMode = 1 << 2, + ShowInHistory = 1 << 3, + ShowBadges = 1 << 4 + }; + Q_ENUM(NotificationBehavior) + Q_DECLARE_FLAGS(NotificationBehaviors, NotificationBehavior) + Q_FLAG(NotificationBehaviors) + + Q_INVOKABLE NotificationBehaviors applicationBehavior(const QString &desktopEntry) const; + Q_INVOKABLE void setApplicationBehavior(const QString &desktopEntry, NotificationBehaviors behaviors); + + Q_INVOKABLE NotificationBehaviors serviceBehavior(const QString &desktopEntry) const; + Q_INVOKABLE void setServiceBehavior(const QString &desktopEntry, NotificationBehaviors behaviors); + + Q_INVOKABLE void registerKnownApplication(const QString &desktopEntry); + Q_INVOKABLE void forgetKnownApplication(const QString &desktopEntry); + + Q_INVOKABLE void load(); + Q_INVOKABLE void save(); + Q_INVOKABLE void defaults(); + + bool live() const; + void setLive(bool live); + + bool dirty() const; + + bool criticalPopupsInDoNotDisturbMode() const; + void setCriticalPopupsInDoNotDisturbMode(bool enable); + + bool keepCriticalAlwaysOnTop() const; + void setKeepCriticalAlwaysOnTop(bool enable); + + bool lowPriorityPopups() const; + void setLowPriorityPopups(bool enable); + + bool lowPriorityHistory() const; + void setLowPriorityHistory(bool enable); + + PopupPosition popupPosition() const; + void setPopupPosition(PopupPosition popupPosition); + + int popupTimeout() const; + void setPopupTimeout(int popupTimeout); + void resetPopupTimeout(); + + bool jobsInTaskManager() const; + void setJobsInTaskManager(bool enable); + + bool jobsInNotifications() const; + void setJobsInNotifications(bool enable); + + bool permanentJobPopups() const; + void setPermanentJobPopups(bool enable); + + bool badgesInTaskManager() const; + void setBadgesInTaskManager(bool enable); + + QStringList knownApplications() const; + + QStringList popupBlacklistedApplications() const; + QStringList popupBlacklistedServices() const; + + QStringList doNotDisturbPopupWhitelistedApplications() const; + QStringList doNotDisturbPopupWhitelistedServices() const; + + QStringList historyBlacklistedApplications() const; + QStringList historyBlacklistedServices() const; + + QDateTime notificationsInhibitedUntil() const; + void setNotificationsInhibitedUntil(const QDateTime &time); + void resetNotificationsInhibitedUntil(); + + bool notificationsInhibitedByApplication() const; + QStringList notificationInhibitionApplications() const; + QStringList notificationInhibitionReasons() const; + + bool notificationSoundsInhibited() const; + void setNotificationSoundsInhibited(bool inhibited); + + bool notificationSoundsMuted() const; + void setNotificationSoundsMuted(bool muted); + + /** + * Revoke application notification inhibitions. + * + * @note Applications are not notified of the fact that their + * inhibition might have been taken away. + */ + Q_INVOKABLE void revokeApplicationInhibitions(); + +signals: + void settingsChanged(); + + void liveChanged(); + void dirtyChanged(); + + void knownApplicationsChanged(); + + void notificationsInhibitedByApplicationChanged(bool notificationsInhibitedByApplication); + void notificationInhibitionApplicationsChanged(); + void notificationSoundsInhibitedChanged(); + void notificationSoundsMutedChanged(); + +private: + class Private; + QScopedPointer d; + +}; + +} // namespace NotificationManager + +Q_DECLARE_OPERATORS_FOR_FLAGS(NotificationManager::Settings::NotificationBehaviors) diff --git a/libnotificationmanager/settings.cpp b/libnotificationmanager/settings.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/settings.cpp @@ -0,0 +1,625 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include "settings.h" + +#include "config-notificationmanager.h" + +#include + +#include +#include + +#include "server.h" +#include "debug.h" + +// Settings +#include "donotdisturbsettings.h" +#include "notificationsettings.h" +#include "jobsettings.h" +#include "badgesettings.h" + +#ifdef HAVE_PULSEAUDIOQT +#include +#include + +using namespace PulseAudioQt; + +static const char *s_notificationStreamId = "sink-input-by-media-role:event"; +#endif + +using namespace NotificationManager; + +class Q_DECL_HIDDEN Settings::Private +{ +public: + explicit Private(Settings *q); + ~Private(); + + void setDirty(bool dirty); + + Settings::NotificationBehaviors groupBehavior(const KConfigGroup &group) const; + void setGroupBehavior(KConfigGroup &group, const Settings::NotificationBehaviors &behavior); + + KConfigGroup servicesGroup() const; + KConfigGroup applicationsGroup() const; + + QStringList behaviorMatchesList(const KConfigGroup &group, Settings::NotificationBehavior behavior, bool on) const; + + Settings *q; + + KSharedConfig::Ptr config; + + KConfigWatcher::Ptr watcher; + QMetaObject::Connection watcherConnection; + + bool live = false; // set to true initially in constructor + bool dirty = false; + +#ifdef HAVE_PULSEAUDIOQT + QPointer notificationSounds; +#endif + +}; + +Settings::Private::Private(Settings *q) + : q(q) +{ + +#ifdef HAVE_PULSEAUDIOQT + // Can there be multiple? + const auto streamRestores = Context::instance()->streamRestores(); + for (StreamRestore *restore : streamRestores) { + if (restore->name() == QLatin1String(s_notificationStreamId)) { + notificationSounds = restore; + connect(notificationSounds.data(), &StreamRestore::mutedChanged, q, &Settings::notificationSoundsMutedChanged); + break; + } + } + + QObject::connect(Context::instance(), &Context::streamRestoreAdded, q, [this, q](StreamRestore *restore) { + if (restore->name() == QLatin1String(s_notificationStreamId)) { + notificationSounds = restore; + connect(notificationSounds.data(), &StreamRestore::mutedChanged, q, &Settings::notificationSoundsMutedChanged); + } + }); +#endif + +} + +Settings::Private::~Private() = default; + +void Settings::Private::setDirty(bool dirty) +{ + if (this->dirty != dirty) { + this->dirty = dirty; + emit q->dirtyChanged(); + } +} + +Settings::NotificationBehaviors Settings::Private::groupBehavior(const KConfigGroup &group) const +{ + Settings::NotificationBehaviors behaviors; + behaviors.setFlag(Settings::ShowPopups, group.readEntry("ShowPopups", true)); + // show popups in dnd mode implies the show popups + behaviors.setFlag(Settings::ShowPopupsInDoNotDisturbMode, behaviors.testFlag(Settings::ShowPopups) && group.readEntry("ShowPopupsInDndMode", false)); + behaviors.setFlag(Settings::ShowInHistory, group.readEntry("ShowInHistory", true)); + behaviors.setFlag(Settings::ShowBadges, group.readEntry("ShowBadges", true)); + return behaviors; +} + +void Settings::Private::setGroupBehavior(KConfigGroup &group, const Settings::NotificationBehaviors &behavior) +{ + if (groupBehavior(group) == behavior) { + return; + } + + const bool showPopups = behavior.testFlag(Settings::ShowPopups); + if (showPopups && !group.hasDefault("ShowPopups")) { + group.revertToDefault("ShowPopups", KConfigBase::Notify); + } else { + group.writeEntry("ShowPopups", showPopups, KConfigBase::Notify); + } + + const bool showPopupsInDndMode = behavior.testFlag(Settings::ShowPopupsInDoNotDisturbMode); + if (!showPopupsInDndMode && !group.hasDefault("ShowPopups")) { + group.revertToDefault("ShowPopupsInDndMode", KConfigBase::Notify); + } else { + group.writeEntry("ShowPopupsInDndMode", showPopupsInDndMode, KConfigBase::Notify); + } + + const bool showInHistory = behavior.testFlag(Settings::ShowInHistory); + if (showInHistory && !group.hasDefault("ShowInHistory")) { + group.revertToDefault("ShowInHistory", KConfig::Notify); + } else { + group.writeEntry("ShowInHistory", showInHistory, KConfigBase::Notify); + } + + const bool showBadges = behavior.testFlag(Settings::ShowBadges); + if (showBadges && !group.hasDefault("ShowBadges")) { + group.revertToDefault("ShowBadges", KConfigBase::Notify); + } else { + group.writeEntry("ShowBadges", showBadges, KConfigBase::Notify); + } + + setDirty(true); +} + +KConfigGroup Settings::Private::servicesGroup() const +{ + return config->group("Services"); +} + +KConfigGroup Settings::Private::applicationsGroup() const +{ + return config->group("Applications"); +} + +QStringList Settings::Private::behaviorMatchesList(const KConfigGroup &group, Settings::NotificationBehavior behavior, bool on) const +{ + QStringList matches; + + const QStringList apps = group.groupList(); + for (const QString &app : apps) { + if (groupBehavior(group.group(app)).testFlag(behavior) == on) { + matches.append(app); + } + } + + return matches; +} + +Settings::Settings(QObject *parent) + // FIXME static thing for config file name + : Settings(KSharedConfig::openConfig(QStringLiteral("plasmanotifyrc")), parent) +{ + +} + +Settings::Settings(const KSharedConfig::Ptr &config, QObject *parent) + : QObject(parent) + , d(new Private(this)) +{ + d->config = config; + + static bool s_settingsInited = false; + if (!s_settingsInited) { + DoNotDisturbSettings::instance(config); + NotificationSettings::instance(config); + JobSettings::instance(config); + BadgeSettings::instance(config); + s_settingsInited = true; + } + + setLive(true); + + connect(&Server::self(), &Server::inhibitedChanged, + this, &Settings::notificationsInhibitedByApplicationChanged); + connect(&Server::self(), &Server::inhibitionApplicationsChanged, + this, &Settings::notificationInhibitionApplicationsChanged); +} + +Settings::~Settings() = default; + +Settings::NotificationBehaviors Settings::applicationBehavior(const QString &desktopEntry) const +{ + return d->groupBehavior(d->applicationsGroup().group(desktopEntry)); +} + +void Settings::setApplicationBehavior(const QString &desktopEntry, NotificationBehaviors behaviors) +{ + KConfigGroup group(d->applicationsGroup().group(desktopEntry)); + d->setGroupBehavior(group, behaviors); +} + +Settings::NotificationBehaviors Settings::serviceBehavior(const QString ¬ifyRcName) const +{ + return d->groupBehavior(d->servicesGroup().group(notifyRcName)); +} + +void Settings::setServiceBehavior(const QString ¬ifyRcName, NotificationBehaviors behaviors) +{ + KConfigGroup group(d->servicesGroup().group(notifyRcName)); + d->setGroupBehavior(group, behaviors); +} + +void Settings::registerKnownApplication(const QString &desktopEntry) +{ + KService::Ptr service = KService::serviceByDesktopName(desktopEntry); + if (!service) { + qCDebug(NOTIFICATIONMANAGER) << "Application" << desktopEntry << "cannot be registered as seen application since there is no service for it"; + return; + } + + if (service->noDisplay()) { + qCDebug(NOTIFICATIONMANAGER) << "Application" << desktopEntry << "will not be registered as seen application since it's marked as NoDisplay"; + return; + } + + if (knownApplications().contains(desktopEntry)) { + return; + } + + d->applicationsGroup().group(desktopEntry).writeEntry("Seen", true); + + emit knownApplicationsChanged(); +} + +void Settings::forgetKnownApplication(const QString &desktopEntry) +{ + if (!knownApplications().contains(desktopEntry)) { + return; + } + + // Only remove applications that were added through registerKnownApplication + if (!d->applicationsGroup().group(desktopEntry).readEntry("Seen", false)) { + qCDebug(NOTIFICATIONMANAGER) << "Application" << desktopEntry << "will not be removed from seen applications since it wasn't one."; + return; + } + + d->applicationsGroup().deleteGroup(desktopEntry); + + emit knownApplicationsChanged(); +} + +void Settings::load() +{ + DoNotDisturbSettings::self()->load(); + NotificationSettings::self()->load(); + JobSettings::self()->load(); + BadgeSettings::self()->load(); + emit settingsChanged(); + d->setDirty(false); +} + +void Settings::save() +{ + DoNotDisturbSettings::self()->save(); + NotificationSettings::self()->save(); + JobSettings::self()->save(); + BadgeSettings::self()->save(); + + d->config->sync(); + d->setDirty(false); +} + +void Settings::defaults() +{ + DoNotDisturbSettings::self()->setDefaults(); + NotificationSettings::self()->setDefaults(); + JobSettings::self()->setDefaults(); + BadgeSettings::self()->setDefaults(); +} + +bool Settings::live() const +{ + return d->live; +} + +void Settings::setLive(bool live) +{ + if (live == d->live) { + return; + } + + d->live = live; + + if (live) { + d->watcher = KConfigWatcher::create(d->config); + d->watcherConnection = connect(d->watcher.data(), &KConfigWatcher::configChanged, this, + [this](const KConfigGroup &group, const QByteArrayList &names) { + Q_UNUSED(names); + + if (group.name() == QLatin1String("DoNotDisturb")) { + DoNotDisturbSettings::self()->load(); + } else if (group.name() == QLatin1String("Notifications")) { + NotificationSettings::self()->load(); + } else if (group.name() == QLatin1String("Jobs")) { + JobSettings::self()->load(); + } else if (group.name() == QLatin1String("Badges")) { + BadgeSettings::self()->load(); + } + + emit settingsChanged(); + }); + } else { + disconnect(d->watcherConnection); + d->watcherConnection = QMetaObject::Connection(); + d->watcher.reset(); + } + + emit liveChanged(); +} + +bool Settings::dirty() const +{ + // KConfigSkeleton doesn't write into the KConfig until calling save() + // so we need to track d->config->isDirty() manually + return d->dirty; +} + +bool Settings::keepCriticalAlwaysOnTop() const +{ + return NotificationSettings::criticalAlwaysOnTop(); +} + +void Settings::setKeepCriticalAlwaysOnTop(bool enable) +{ + if (this->keepCriticalAlwaysOnTop() == enable) { + return; + } + NotificationSettings::setCriticalAlwaysOnTop(enable); + d->setDirty(true); +} + +bool Settings::criticalPopupsInDoNotDisturbMode() const +{ + return NotificationSettings::criticalInDndMode(); +} + +void Settings::setCriticalPopupsInDoNotDisturbMode(bool enable) +{ + if (this->criticalPopupsInDoNotDisturbMode() == enable) { + return; + } + NotificationSettings::setCriticalInDndMode(enable); + d->setDirty(true); +} + +bool Settings::lowPriorityPopups() const +{ + return NotificationSettings::lowPriorityPopups(); +} + +void Settings::setLowPriorityPopups(bool enable) +{ + if (this->lowPriorityPopups() == enable) { + return; + } + NotificationSettings::setLowPriorityPopups(enable); + d->setDirty(true); +} + +bool Settings::lowPriorityHistory() const +{ + return NotificationSettings::lowPriorityHistory(); +} + +void Settings::setLowPriorityHistory(bool enable) +{ + if (this->lowPriorityHistory() == enable) { + return; + } + NotificationSettings::setLowPriorityHistory(enable); + d->setDirty(true); +} + +Settings::PopupPosition Settings::popupPosition() const +{ + return NotificationSettings::popupPosition(); +} + +void Settings::setPopupPosition(Settings::PopupPosition position) +{ + if (this->popupPosition() == position) { + return; + } + NotificationSettings::setPopupPosition(position); + d->setDirty(true); +} + +int Settings::popupTimeout() const +{ + return NotificationSettings::popupTimeout(); +} + +void Settings::setPopupTimeout(int timeout) +{ + if (this->popupTimeout() == timeout) { + return; + } + NotificationSettings::setPopupTimeout(timeout); + d->setDirty(true); +} + +void Settings::resetPopupTimeout() +{ + setPopupTimeout(NotificationSettings::defaultPopupTimeoutValue()); +} + +bool Settings::jobsInTaskManager() const +{ + return JobSettings::inTaskManager(); +} + +void Settings::setJobsInTaskManager(bool enable) +{ + if (jobsInTaskManager() == enable) { + return; + } + JobSettings::setInTaskManager(enable); + d->setDirty(true); +} + +bool Settings::jobsInNotifications() const +{ + return JobSettings::inNotifications(); +} +void Settings::setJobsInNotifications(bool enable) +{ + if (jobsInNotifications() == enable) { + return; + } + JobSettings::setInNotifications(enable); + d->setDirty(true); +} + +bool Settings::permanentJobPopups() const +{ + return JobSettings::permanentPopups(); +} + +void Settings::setPermanentJobPopups(bool enable) +{ + if (permanentJobPopups() == enable) { + return; + } + JobSettings::setPermanentPopups(enable); + d->setDirty(true); +} + +bool Settings::badgesInTaskManager() const +{ + return BadgeSettings::inTaskManager(); +} + +void Settings::setBadgesInTaskManager(bool enable) +{ + if (badgesInTaskManager() == enable) { + return; + } + BadgeSettings::setInTaskManager(enable); + d->setDirty(true); +} + +QStringList Settings::knownApplications() const +{ + return d->applicationsGroup().groupList(); +} + +QStringList Settings::popupBlacklistedApplications() const +{ + return d->behaviorMatchesList(d->applicationsGroup(), ShowPopups, false); +} + +QStringList Settings::popupBlacklistedServices() const +{ + return d->behaviorMatchesList(d->servicesGroup(), ShowPopups, false); +} + +QStringList Settings::doNotDisturbPopupWhitelistedApplications() const +{ + return d->behaviorMatchesList(d->applicationsGroup(), ShowPopupsInDoNotDisturbMode, true); +} + +QStringList Settings::doNotDisturbPopupWhitelistedServices() const +{ + return d->behaviorMatchesList(d->servicesGroup(), ShowPopupsInDoNotDisturbMode, true); +} + +QStringList Settings::historyBlacklistedApplications() const +{ + return d->behaviorMatchesList(d->applicationsGroup(), ShowInHistory, false); +} + +QStringList Settings::historyBlacklistedServices() const +{ + return d->behaviorMatchesList(d->servicesGroup(), ShowInHistory, false); +} + +QDateTime Settings::notificationsInhibitedUntil() const +{ + return DoNotDisturbSettings::until(); +} + +void Settings::setNotificationsInhibitedUntil(const QDateTime &time) +{ + DoNotDisturbSettings::setUntil(time); + d->setDirty(true); +} + +void Settings::resetNotificationsInhibitedUntil() +{ + setNotificationsInhibitedUntil(QDateTime());// FIXME DoNotDisturbSettings::defaultUntilValue()); +} + +bool Settings::notificationsInhibitedByApplication() const +{ + return Server::self().inhibited(); +} + +QStringList Settings::notificationInhibitionApplications() const +{ + return Server::self().inhibitionApplications(); +} + +QStringList Settings::notificationInhibitionReasons() const +{ + return Server::self().inhibitionReasons(); +} + +void Settings::revokeApplicationInhibitions() +{ + Server::self().clearInhibitions(); +} + +bool Settings::notificationSoundsInhibited() const +{ +#ifdef HAVE_PULSEAUDIOQT + return DoNotDisturbSettings::notificationSoundsMuted(); +#else + return false; +#endif +} + +bool Settings::notificationSoundsMuted() const +{ +#ifdef HAVE_PULSEAUDIOQT + return !d->notificationSounds.isNull() & d->notificationSounds->isMuted(); +#else + return false; +#endif +} + +void Settings::setNotificationSoundsMuted(bool muted) +{ +#ifdef HAVE_PULSEAUDIOQT + if (d->notificationSounds) { + d->notificationSounds->setMuted(muted); + } +#else + Q_UNUSED(muted); +#endif +} + +void Settings::setNotificationSoundsInhibited(bool inhibited) +{ + if (inhibited == notificationSoundsInhibited()) { + return; + } + +#ifdef HAVE_PULSEAUDIOQT + if (!d->notificationSounds) { + qCInfo(NOTIFICATIONMANAGER) << "Cannot" << (inhibited ? "mute" : "unmute") << "notification sounds without appropriate stream restore"; + return; + } + + if (inhibited && d->notificationSounds->isMuted()) { + // Already muted, don't write soundsMuted=true to avoid us unmuting them later + qCDebug(NOTIFICATIONMANAGER) << "Not muting notification sounds as they already are"; + return; + } + + d->notificationSounds->setMuted(inhibited); + + DoNotDisturbSettings::setNotificationSoundsMuted(inhibited); + d->setDirty(true); +#endif +}