diff --git a/libnotificationmanager/CMakeLists.txt b/libnotificationmanager/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/libnotificationmanager/CMakeLists.txt @@ -0,0 +1,114 @@ +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 + jobsmodel_p.cpp + job.cpp + job_p.cpp + + limitedrowcountproxymodel.cpp + + utils.cpp +) + +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 +# Notifications +qt5_add_dbus_adaptor(notificationmanager_LIB_SRCS dbus/org.freedesktop.Notifications.xml server_p.h NotificationManager::ServerPrivate) +# JobView +qt5_add_dbus_adaptor(notificationmanager_LIB_SRCS dbus/org.kde.kuiserver.xml jobsmodel_p.h NotificationManager::JobsModelPrivate) +qt5_add_dbus_adaptor(notificationmanager_LIB_SRCS dbus/org.kde.JobViewServer.xml jobsmodel_p.h NotificationManager::JobsModelPrivate) +qt5_add_dbus_adaptor(notificationmanager_LIB_SRCS dbus/org.kde.JobViewServerV2.xml jobsmodel_p.h NotificationManager::JobsModelPrivate) +qt5_add_dbus_adaptor(notificationmanager_LIB_SRCS dbus/org.kde.JobViewV2.xml job_p.h NotificationManager::JobPrivate) +qt5_add_dbus_adaptor(notificationmanager_LIB_SRCS dbus/org.kde.JobViewV3.xml job_p.h NotificationManager::JobPrivate) + +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 +) + +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 + notifications.h + notification.h + jobsmodel.h + job.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/autotests/CMakeLists.txt b/libnotificationmanager/autotests/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/libnotificationmanager/autotests/CMakeLists.txt @@ -0,0 +1,8 @@ +include(ECMMarkAsTest) + +set(notifications_test_SRCS + notifications_test.cpp +) +add_executable(notification_test ${notifications_test_SRCS}) +target_link_libraries(notification_test Qt5::Test Qt5::Core PW::LibNotificationManager) +ecm_mark_as_test(notification_test) diff --git a/libnotificationmanager/autotests/notifications_test.cpp b/libnotificationmanager/autotests/notifications_test.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/autotests/notifications_test.cpp @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2017 David Edmundson + * + * This program is free software you can redistribute it and/or + * modify it under the terms of the GNU Library General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program 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 + * Library General Public License for more details. + * + * You should have received a copy of the GNU Library General Public License + * along with this library; see the file COPYING.LIB. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. +*/ + +#include +#include +#include + +#include "notification.h" + +class NotificationTest : public QObject +{ + Q_OBJECT +public: + NotificationTest() {} +private Q_SLOTS: + void parse_data(); + void parse(); +}; + +void NotificationTest::parse_data() +{ + QTest::addColumn("messageIn"); + QTest::addColumn("expectedOut"); + + QTest::newRow("basic no HTML") << "I am a notification" << "I am a notification"; + QTest::newRow("whitespace") << " I am a notification " << "I am a notification"; + + QTest::newRow("basic html") << "I am the notification" << "I am the notification"; + QTest::newRow("nested html") << "I am the notification" << "I am the notification"; + + QTest::newRow("no extra tags") << "I am the notification" << "I am the notification"; + QTest::newRow("no extra attrs") << "I am the notification" << "I am the notification"; + + QTest::newRow("newlines") << "I am\nthe\nnotification" << "I am
the
notification"; + QTest::newRow("multinewlines") << "I am\n\nthe\n\n\nnotification" << "I am
the
notification"; + + QTest::newRow("amp") << "me&you" << "me&you"; + QTest::newRow("double escape") << "foo & <bar>" << "foo & <bar>"; + + QTest::newRow("quotes") << "'foo'" << "'foo'";//as label can't handle this normally valid entity + + QTest::newRow("image normal") << "This is \"cheese\"/ and more text" << "This is \"cheese\"/ and more text"; + + //this input is technically wrong, so the output is also wrong, but QTextHtmlParser does the "right" thing + QTest::newRow("image normal no close") << "This is \"cheese\" and more text" << "This is \"cheese\" and more text"; + + QTest::newRow("image remote URL") << "This is \"cheese\" and more text" << "This is \"cheese\"/ and more text"; + + //more bad formatted options. To some extent actual output doesn't matter. Garbage in, garbabe out. + //the important thing is that it doesn't contain anything that could be parsed as the remote URL + QTest::newRow("image remote URL no close") << "This is \" alt=\"cheese\"> and more text" << "This is \"cheese\" and more text"; + QTest::newRow("image remote URL double open") << "This is <\" and more text" << "This is "; + QTest::newRow("image remote URL no entitiy close") << "This is \"cheese\" and more text" << "This is "; + + QTest::newRow("link") << "This is a link and more text" << "This is a link and more text"; +} + +void NotificationTest::parse() +{ + QFETCH(QString, messageIn); + QFETCH(QString, expectedOut); + + NotificationManager::Notification notification; + notification.setBody(messageIn); + + expectedOut = "" + expectedOut + "\n"; + + QCOMPARE(notification.body(), expectedOut); +} + +QTEST_GUILESS_MAIN(NotificationTest) + +#include "notifications_test.moc" diff --git a/libnotificationmanager/dbus/org.freedesktop.Notifications.xml b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml new file mode 100644 --- /dev/null +++ b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libnotificationmanager/dbus/org.kde.JobViewServer.xml b/libnotificationmanager/dbus/org.kde.JobViewServer.xml new file mode 100644 --- /dev/null +++ b/libnotificationmanager/dbus/org.kde.JobViewServer.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/libnotificationmanager/dbus/org.kde.JobViewServerV2.xml b/libnotificationmanager/dbus/org.kde.JobViewServerV2.xml new file mode 100644 --- /dev/null +++ b/libnotificationmanager/dbus/org.kde.JobViewServerV2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/libnotificationmanager/dbus/org.kde.JobViewV2.xml b/libnotificationmanager/dbus/org.kde.JobViewV2.xml new file mode 100644 --- /dev/null +++ b/libnotificationmanager/dbus/org.kde.JobViewV2.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libnotificationmanager/dbus/org.kde.JobViewV3.xml b/libnotificationmanager/dbus/org.kde.JobViewV3.xml new file mode 100644 --- /dev/null +++ b/libnotificationmanager/dbus/org.kde.JobViewV3.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libnotificationmanager/dbus/org.kde.kuiserver.xml b/libnotificationmanager/dbus/org.kde.kuiserver.xml new file mode 100644 --- /dev/null +++ b/libnotificationmanager/dbus/org.kde.kuiserver.xml @@ -0,0 +1,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 "job.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,242 @@ +/* + * 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 "notifications.h" + +#include "notificationmanager_export.h" + +namespace NotificationManager +{ + +class JobPrivate; + +class NOTIFICATIONMANAGER_EXPORT Job : public QObject +{ + Q_OBJECT + + /** + * The job infoMessage, e.g. "Copying". + */ + Q_PROPERTY(QString summary READ summary NOTIFY summaryChanged) + /** + * 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 desktop entry of the application owning the job, e.g. "org.kde.dolphin". + */ + Q_PROPERTY(QString desktopEntry READ desktopEntry CONSTANT) + /** + * The user-visible name of the application owning the job, e.g. "Dolphin". + */ + Q_PROPERTY(QString applicationName READ applicationName CONSTANT) + /** + * The icon name of the application owning the job, e.g. "system-file-manager". + */ + Q_PROPERTY(QString applicationIconName READ applicationIconName CONSTANT) + /** + * The state the job is currently in. + */ + Q_PROPERTY(Notifications::JobState state READ state NOTIFY stateChanged) + /** + * The total percentage (0-100) of job completion. + */ + Q_PROPERTY(int percentage READ percentage NOTIFY percentageChanged) + /** + * The error code of the job failure. + */ + Q_PROPERTY(int error READ error NOTIFY errorChanged) + /** + * Whether the job can be suspended. + * + * @sa Notifications::suspendJob + * @sa Notifications::resumeJob + */ + Q_PROPERTY(bool suspendable READ suspendable CONSTANT) + /** + * Whether the job can be aborted. + * + * @sa Notifications::killJob + */ + Q_PROPERTY(bool killable READ killable CONSTANT) + + /** + * 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) + + /** + * Whether there are any details available for this job. + * + * This is true as soon as any of the following are available: + * - processed amount (of any unit) + * - total amount (of any unit) + * - description label or value (of any row) + * - speed + */ + Q_PROPERTY(bool hasDetails READ hasDetails NOTIFY hasDetailsChanged) + + /** + * 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 Job(uint id, QObject *parent = nullptr); + ~Job() override; + + uint id() const; + + QDateTime created() const; + + QDateTime updated() const; + void resetUpdated(); + + QString summary() const; + QString text() const; + + QString desktopEntry() const; + // TODO remove and let only constructor do it? + void setDesktopEntry(const QString &desktopEntry); + + QString applicationName() const; + // TODO remove and let only constructor do it? + void setApplicationName(const QString &applicationName); + + QString applicationIconName() const; + // TODO remove and let only constructor do it? + void setApplicationIconName(const QString &applicationIconName); + + Notifications::JobState state() const; + void setState(Notifications::JobState jobState); + + int percentage() const; + + int error() const; + void setError(int error); + + QString errorText() const; + void setErrorText(const QString &errorText); + + bool suspendable() const; + // TODO remove and let only constructor do it? + void setSuspendable(bool suspendable); + + bool killable() const; + // TODO remove and let only constructor do it? + void setKillable(bool killable); + + 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; + + bool hasDetails() const; + + QUrl descriptionUrl() const; + + bool expired() const; + void setExpired(bool expired); + + bool dismissed() const; + void setDismissed(bool dismissed); + + Q_INVOKABLE void suspend(); + Q_INVOKABLE void resume(); + Q_INVOKABLE void kill(); + +signals: + void updatedChanged(); + void summaryChanged(); + void textChanged(); + void stateChanged(Notifications::JobState jobState); + void percentageChanged(int percentage); + void errorChanged(int error); + void errorTextChanged(const QString &errorText); + 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(); + void hasDetailsChanged(); + void expiredChanged(); + void dismissedChanged(); + +private: + JobPrivate *d; + + friend class JobsModel; + friend class JobsModelPrivate; + +}; + +} // 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,280 @@ +/* + * 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 "job_p.h" + +#include + +#include + +#include "notifications.h" + +using namespace NotificationManager; + +Job::Job(uint id, QObject *parent) + : QObject(parent) + , d(new JobPrivate(id, this)) +{ + d->m_created = QDateTime::currentDateTimeUtc(); +} + +Job::~Job() = default; + +uint Job::id() const +{ + return d->m_id; +} + +QDateTime Job::created() const +{ + return d->m_created; +} + +QDateTime Job::updated() const +{ + return d->m_updated; +} + +void Job::resetUpdated() +{ + d->m_updated = QDateTime::currentDateTimeUtc(); + emit updatedChanged(); +} + +QString Job::summary() const +{ + return d->m_summary; +} + +QString Job::text() const +{ + return d->text(); +} + +QString Job::desktopEntry() const +{ + return d->m_desktopEntry; +} + +void Job::setDesktopEntry(const QString &desktopEntry) +{ + Q_ASSERT(d->m_desktopEntry.isNull()); + d->m_desktopEntry = desktopEntry; +} + +QString Job::applicationName() const +{ + return d->m_applicationName; +} + +void Job::setApplicationName(const QString &applicationName) +{ + Q_ASSERT(d->m_applicationName.isNull()); + d->m_applicationName = applicationName; +} + +QString Job::applicationIconName() const +{ + return d->m_applicationIconName; +} + +void Job::setApplicationIconName(const QString &applicationIconName) +{ + Q_ASSERT(d->m_applicationIconName.isNull()); + d->m_applicationIconName = applicationIconName; +} + +Notifications::JobState Job::state() const +{ + return d->m_state; +} + +void Job::setState(Notifications::JobState state) +{ + if (d->m_state != state) { + d->m_state = state; + emit stateChanged(state); + } +} + +int Job::percentage() const +{ + return d->m_percentage; +} + +int Job::error() const +{ + return d->m_error; +} + +void Job::setError(int error) +{ + if (d->m_error != error) { + d->m_error = error; + emit errorChanged(error); + } +} + +QString Job::errorText() const +{ + return d->m_errorText; +} + +void Job::setErrorText(const QString &errorText) +{ + if (d->m_errorText != errorText) { + d->m_errorText = errorText; + emit errorTextChanged(errorText); + } +} + +bool Job::suspendable() const +{ + return d->m_suspendable; +} + +void Job::setSuspendable(bool suspendable) +{ + // Cannot change after job started + d->m_suspendable = suspendable; +} + +bool Job::killable() const +{ + return d->m_killable; +} + +void Job::setKillable(bool killable) +{ + // Cannot change after job started + d->m_killable = killable; +} + +QUrl Job::destUrl() const +{ + return d->m_destUrl; +} + +qulonglong Job::speed() const +{ + return d->m_speed; +} + +qulonglong Job::processedBytes() const +{ + return d->m_processedBytes; +} + +qulonglong Job::processedFiles() const +{ + return d->m_processedFiles; +} + +qulonglong Job::processedDirectories() const +{ + return d->m_processedDirectories; +} + +qulonglong Job::totalBytes() const +{ + return d->m_totalBytes; +} + +qulonglong Job::totalFiles() const +{ + return d->m_totalFiles; +} + +qulonglong Job::totalDirectories() const +{ + return d->m_totalDirectories; +} + +QString Job::descriptionLabel1() const +{ + return d->m_descriptionLabel1; +} + +QString Job::descriptionValue1() const +{ + return d->m_descriptionValue1; +} + +QString Job::descriptionLabel2() const +{ + return d->m_descriptionLabel2; +} + +QString Job::descriptionValue2() const +{ + return d->m_descriptionValue2; +} + +bool Job::hasDetails() const +{ + return d->m_hasDetails; +} + +QUrl Job::descriptionUrl() const +{ + return d->descriptionUrl(); +} + +bool Job::expired() const +{ + return d->m_expired; +} + +void Job::setExpired(bool expired) +{ + if (d->m_expired != expired) { + d->m_expired = expired; + emit expiredChanged(); + } +} + +bool Job::dismissed() const +{ + return d->m_dismissed; +} + +void Job::setDismissed(bool dismissed) +{ + if (d->m_dismissed != dismissed) { + d->m_dismissed = dismissed; + emit dismissedChanged(); + } +} + +void Job::suspend() +{ + emit d->suspendRequested(); +} + +void Job::resume() +{ + emit d->resumeRequested();; +} + +void Job::kill() +{ + emit d->cancelRequested(); +} diff --git a/libnotificationmanager/job_p.h b/libnotificationmanager/job_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/job_p.h @@ -0,0 +1,151 @@ +/* + * 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 +#include + +#include "notifications.h" +#include "job.h" + +class KFilePlacesModel; + +namespace NotificationManager +{ + +class JobDetails; + +class JobPrivate : public QObject, protected QDBusContext +{ + Q_OBJECT + +public: + JobPrivate(uint id, QObject *parent); + ~JobPrivate() override; + + QDBusObjectPath objectPath() const; + QUrl descriptionUrl() const; + QString text() const; + + // DBus + // JobViewV1 + void terminate(const QString &errorMessage); + void setSuspended(bool suspended); + void setTotalAmount(quint64 amount, const QString &unit); + void setProcessedAmount(quint64 amount, const QString &unit); + void setPercent(uint percent); + void setSpeed(quint64 bytesPerSecond); + void setInfoMessage(const QString &infoMessage); + bool setDescriptionField(uint number, const QString &name, const QString &value); + void clearDescriptionField(uint number); + void setDestUrl(const QDBusVariant &urlVariant); + void setError(uint errorCode); + + // JobViewV2 + void terminate(uint errorCode, const QString &errorMessage, const QVariantMap &hints); + void update(const QVariantMap &properties); + +signals: + void closed(); + + // DBus + // V1 and V2 + void suspendRequested(); + void resumeRequested(); + void cancelRequested(); + // V2 + void updateRequested(); + +private: + friend class Job; + + template bool updateField(const T &newValue, + T &target, + void (Job::*changeSignal)()) + { + if (target != newValue) { + target = newValue; + emit ((static_cast(parent()))->*changeSignal)(); + return true; + } + return false; + } + + static QSharedPointer createPlacesModel(); + + QString prettyDestUrl() const; + void updateHasDetails(); + + void finish(); + + uint m_id = 0; + QDBusObjectPath m_objectPath; + + QDateTime m_created; + QDateTime m_updated; + + QString m_summary; + + QString m_desktopEntry; + QString m_applicationName; + QString m_applicationIconName; + + Notifications::JobState m_state = Notifications::JobStateRunning; + int m_percentage = 0; + int m_error = 0; + QString m_errorText; + bool m_suspendable = false; + bool m_killable = false; + + 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; + + bool m_hasDetails = false; + + bool m_expired = false; + bool m_dismissed = false; + + mutable QSharedPointer m_placesModel; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/job_p.cpp b/libnotificationmanager/job_p.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/job_p.cpp @@ -0,0 +1,329 @@ +/* + * 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_p.h" +#include "job.h" + +#include "debug.h" + +#include +#include + +#include +#include +#include + +#include "notifications.h" + +#include "jobviewv2adaptor.h" +#include "jobviewv3adaptor.h" + +#include + +using namespace NotificationManager; + +JobPrivate::JobPrivate(uint id, QObject *parent) + : QObject(parent) + , m_id(id) + , m_placesModel(createPlacesModel()) +{ + m_objectPath.setPath(QStringLiteral("/org/kde/notificationmanager/jobs/JobView_%1").arg(id)); + + // TODO also v1? it's identical to V2 except it doesn't have setError method so supporting it should be easy + new JobViewV2Adaptor(this); + new JobViewV3Adaptor(this); + + QDBusConnection::sessionBus().registerObject(m_objectPath.path(), this); +} + +JobPrivate::~JobPrivate() = default; + +QDBusObjectPath JobPrivate::objectPath() const +{ + return m_objectPath; +} + +QSharedPointer JobPrivate::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 JobPrivate::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(QUrl::RemoveUserInfo); +} + +void JobPrivate::updateHasDetails() +{ + const bool hasDetails = m_totalBytes > 0 + || m_totalFiles > 0 + || m_totalDirectories > 0 + || m_processedBytes > 0 + || m_processedFiles > 0 + || m_processedDirectories > 0 + || !m_descriptionLabel1.isEmpty() + || !m_descriptionLabel2.isEmpty() + || !m_descriptionValue1.isEmpty() + || !m_descriptionValue2.isEmpty() + || m_speed > 0; + + if (m_hasDetails != hasDetails) { + m_hasDetails = hasDetails; + emit static_cast(parent())->hasDetailsChanged(); + } +} + +QString JobPrivate::text() const +{ + if (!m_errorText.isEmpty()) { + return m_errorText; + } + + 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(); +} + +QUrl JobPrivate::descriptionUrl() const +{ + QUrl url = QUrl::fromUserInput(m_descriptionValue2, QString(), QUrl::AssumeLocalFile); + if (!url.isValid()) { + url = QUrl::fromUserInput(m_descriptionValue1, QString(), QUrl::AssumeLocalFile); + } + return url; +} + +void JobPrivate::finish() +{ + // Unregister the dbus service since the client is done with it + QDBusConnection::sessionBus().unregisterObject(m_objectPath.path()); + + // When user canceled transfer, remove it without notice + if (m_error == 1) { // KIO::ERR_USER_CANCELED + emit closed(); + return; + } + + Job *job = static_cast(parent()); + // update timestamp + job->resetUpdated(); + // when it was hidden in history, bring it up again + job->setDismissed(false); +} + +// JobViewV2 +void JobPrivate::terminate(const QString &errorMessage) +{ + Job *job = static_cast(parent()); + job->setErrorText(errorMessage); + job->setState(Notifications::JobStateStopped); + finish(); +} + +void JobPrivate::setSuspended(bool suspended) +{ + Job *job = static_cast(parent()); + if (suspended) { + job->setState(Notifications::JobStateSuspended); + } else { + job->setState(Notifications::JobStateRunning); + } +} + +void JobPrivate::setTotalAmount(quint64 amount, const QString &unit) +{ + if (unit == QLatin1String("bytes")) { + updateField(amount, m_totalBytes, &Job::totalBytesChanged); + } else if (unit == QLatin1String("files")) { + updateField(amount, m_totalFiles, &Job::totalFilesChanged); + } else if (unit == QLatin1String("dirs")) { + updateField(amount, m_totalDirectories, &Job::totalDirectoriesChanged); + } + updateHasDetails(); +} + +void JobPrivate::setProcessedAmount(quint64 amount, const QString &unit) +{ + if (unit == QLatin1String("bytes")) { + updateField(amount, m_processedBytes, &Job::processedBytesChanged); + } else if (unit == QLatin1String("files")) { + updateField(amount, m_processedFiles, &Job::processedFilesChanged); + } else if (unit == QLatin1String("dirs")) { + updateField(amount, m_processedDirectories, &Job::processedDirectoriesChanged); + } + updateHasDetails(); +} + +void JobPrivate::setPercent(uint percent) +{ + const int percentage = static_cast(percent); + if (m_percentage != percentage) { + m_percentage = percentage; + emit static_cast(parent())->percentageChanged(percentage); + } +} + +void JobPrivate::setSpeed(quint64 bytesPerSecond) +{ + updateField(bytesPerSecond, m_speed, &Job::speedChanged); + updateHasDetails(); +} + +void JobPrivate::setInfoMessage(const QString &infoMessage) +{ + updateField(infoMessage, m_summary, &Job::summaryChanged); +} + +bool JobPrivate::setDescriptionField(uint number, const QString &name, const QString &value) +{ + if (number == 0) { + updateField(name, m_descriptionLabel1, &Job::descriptionLabel1Changed); + updateField(value, m_descriptionValue1, &Job::descriptionValue1Changed); + } else if (number == 1) { + updateField(name, m_descriptionLabel2, &Job::descriptionLabel2Changed); + updateField(value, m_descriptionValue2, &Job::descriptionValue2Changed); + } + updateHasDetails(); + + return false; +} + +void JobPrivate::clearDescriptionField(uint number) +{ + setDescriptionField(number, QString(), QString()); +} + +void JobPrivate::setDestUrl(const QDBusVariant &urlVariant) +{ + const QUrl destUrl = QUrl(urlVariant.variant().toUrl().adjusted(QUrl::StripTrailingSlash)); // urgh + updateField(destUrl, m_destUrl, &Job::destUrlChanged); +} + +void JobPrivate::setError(uint errorCode) +{ + static_cast(parent())->setError(errorCode); +} + +// JobViewV3 +void JobPrivate::terminate(uint errorCode, const QString &errorMessage, const QVariantMap &hints) +{ + Q_UNUSED(hints) // reserved for future extension + + Job *job = static_cast(parent()); + job->setError(errorCode); + job->setErrorText(errorMessage); + job->setState(Notifications::JobStateStopped); + finish(); +} + +void JobPrivate::update(const QVariantMap &properties) +{ + // TODO + sendErrorReply(QDBusError::NotSupported, QStringLiteral("JobViewV3 update is not yet implemented.")); + Q_UNUSED(properties) +} diff --git a/libnotificationmanager/jobsmodel.h b/libnotificationmanager/jobsmodel.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobsmodel.h @@ -0,0 +1,95 @@ +/* + * 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 "notifications.h" +#include "notificationmanager_export.h" + +namespace NotificationManager +{ + +class JobsModelPrivate; + +class NOTIFICATIONMANAGER_EXPORT JobsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + ~JobsModel() override; + + using Ptr = QSharedPointer; + static Ptr createJobsModel(); + + /** + * Registers the JobView service on DBus. + * + * @return true if succeeded, false otherwise. + */ + bool init(); + + /** + * Whether the notification service could be reigstered + */ + bool isValid() const; + + 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; + + /** + * @brief Close a job + * + * This removes the job from the model. This will not cancel the job! + * Use @c kill if you want to cancel a job. + */ + void close(const QModelIndex &idx); + void expire(const QModelIndex &idx); + + /** + * @brief Suspend a job + */ + void suspend(const QModelIndex &idx); + /** + * @brief Resume a job + */ + void resume(const QModelIndex &idx); + /** + * @brief Kill a job + * + * This cancels the job. + */ + void kill(const QModelIndex &idx); + + void clear(Notifications::ClearFlags flags); + +private: + JobsModel(); + Q_DISABLE_COPY(JobsModel) + + JobsModelPrivate *d; + +}; + +} // 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,229 @@ +/* + * 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 "jobsmodel_p.h" + +#include "notifications.h" + +#include +#include + +#include + +#include +#include +#include + +#include "job.h" +#include "job_p.h" + +using namespace NotificationManager; + +JobsModel::JobsModel() + : QAbstractListModel(nullptr) + , d(new JobsModelPrivate(this)) +{ + connect(d, &JobsModelPrivate::jobViewAboutToBeAdded, this, [this](int row, Job *job) { + Q_UNUSED(job); + beginInsertRows(QModelIndex(), row, row); + }); + connect(d, &JobsModelPrivate::jobViewAdded, this, [this](int row) { + Q_UNUSED(row); + endInsertRows(); + }); + + connect(d, &JobsModelPrivate::jobViewAboutToBeRemoved, this, [this](int row) { + beginRemoveRows(QModelIndex(), row, row); + }); + connect(d, &JobsModelPrivate::jobViewRemoved, this, [this](int row) { + Q_UNUSED(row); + endRemoveRows(); + }); + + connect(d, &JobsModelPrivate::jobViewChanged, this, [this](int row, Job *job, const QVector &roles) { + Q_UNUSED(job); + const QModelIndex idx = index(row, 0); + emit dataChanged(idx, idx, roles); + }); +} + +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(); +} + +bool JobsModel::init() +{ + return d->init(); +} + +bool JobsModel::isValid() const +{ + return d->m_valid; +} + +QVariant JobsModel::data(const QModelIndex &index, int role) const +{ + if (!checkIndex(index)) { + return QVariant(); + } + + Job *job = d->m_jobViews.at(index.row()); + + switch (role) { + case Notifications::IdRole: return job->id(); + 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::BodyRole: return job->text(); + 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::JobErrorRole: return job->error(); + case Notifications::SuspendableRole: return job->suspendable(); + case Notifications::KillableRole: return job->killable(); + case Notifications::JobDetailsRole: return QVariant::fromValue(job); + + // 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 (!checkIndex(index)) { + return false; + } + + Job *job = d->m_jobViews.at(index.row()); + + 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->m_jobViews.count(); +} + +void JobsModel::close(const QModelIndex &idx) +{ + if (checkIndex(idx)) { + d->removeAt(idx.row()); + } +} + +void JobsModel::expire(const QModelIndex &idx) +{ + if (checkIndex(idx)) { + d->m_jobViews.at(idx.row())->setExpired(true); + } +} + +void JobsModel::suspend(const QModelIndex &idx) +{ + if (checkIndex(idx)) { + emit d->m_jobViews.at(idx.row())->suspend(); + } +} + +void JobsModel::resume(const QModelIndex &idx) +{ + if (checkIndex(idx)) { + emit d->m_jobViews.at(idx.row())->resume(); + } +} + +void JobsModel::kill(const QModelIndex &idx) +{ + if (checkIndex(idx)) { + emit d->m_jobViews.at(idx.row())->kill(); + } +} + +void JobsModel::clear(Notifications::ClearFlags flags) +{ + if (d->m_jobViews.isEmpty()) { + return; + } + + for (int i = d->m_jobViews.count() - 1; i >= 0; --i) { + Job *job = d->m_jobViews.at(i); + + 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) { + d->removeAt(i); + } + } +} diff --git a/libnotificationmanager/jobsmodel_p.h b/libnotificationmanager/jobsmodel_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobsmodel_p.h @@ -0,0 +1,103 @@ +/* + * 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 "notifications.h" + +class QDBusServiceWatcher; +class QTimer; + +namespace NotificationManager +{ + +class Job; + +class Q_DECL_HIDDEN JobsModelPrivate : public QObject, protected QDBusContext +{ + Q_OBJECT + +public: + JobsModelPrivate(QObject *parent); + ~JobsModelPrivate() override; + + // DBus + // kuiserver + void registerService(const QString &service, const QString &objectPath); + void emitJobUrlsChanged(); + bool requiresJobTracker() const; + QStringList registeredJobContacts() const; + // V1 + QDBusObjectPath requestView(const QString &appName, const QString &appIconName, int capabilities); + // V2 + QDBusObjectPath requestView(const QString &desktopEntry, + int capabilities, + const QVariantMap &hints); + +Q_SIGNALS: + void jobViewAboutToBeAdded(int row, Job *job); + void jobViewAdded(int row, Job *job); + + void jobViewAboutToBeRemoved(int row);//, Job *job); + void jobViewRemoved(int row); + + void jobViewChanged(int row, Job *job, const QVector &roles); + + // DBus + // kuiserver + void jobUrlsChanged(const QStringList &urls); + +public: // stuff used by public class + bool init(); + + void remove(Job *job); + void removeAt(int row); + + bool m_valid = false; + QVector m_jobViews; + +private: + void onServiceUnregistered(const QString &serviceName); + + void updateApplicationPercentage(const QString &desktopEntry); + + QStringList jobUrls() const; + void scheduleUpdate(Job *job, Notifications::Roles role); + + QDBusServiceWatcher *m_serviceWatcher = nullptr; + // Job -> serviceName + QHash m_jobServices; + int m_highestJobId = 1; + + QTimer *m_compressUpdatesTimer = nullptr; + QHash> m_pendingDirtyRoles; + + QTimer *m_pendingJobViewsTimer = nullptr; + QVector m_pendingJobViews; + +}; + +} // namespace NotificationManager diff --git a/libnotificationmanager/jobsmodel_p.cpp b/libnotificationmanager/jobsmodel_p.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/jobsmodel_p.cpp @@ -0,0 +1,432 @@ +/* + * 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_p.h" + +#include "debug.h" + +#include "job.h" +#include "job_p.h" + +#include "utils_p.h" + +#include "kuiserveradaptor.h" +#include "jobviewserveradaptor.h" +#include "jobviewserverv2adaptor.h" + +#include +#include + +#include +#include +#include + +#include + +using namespace NotificationManager; + +JobsModelPrivate::JobsModelPrivate(QObject *parent) + : QObject(parent) + , m_compressUpdatesTimer(new QTimer(this)) + , m_pendingJobViewsTimer(new QTimer(this)) +{ + m_compressUpdatesTimer->setInterval(0); + m_compressUpdatesTimer->setSingleShot(true); + connect(m_compressUpdatesTimer, &QTimer::timeout, this, [this] { + for (auto it = m_pendingDirtyRoles.constBegin(), end = m_pendingDirtyRoles.constEnd(); it != end; ++it) { + Job *job = it.key(); + const QVector roles = it.value(); + const int row = m_jobViews.indexOf(job); + if (row == -1) { + continue; + } + + emit jobViewChanged(row, job, roles); + + // This is updated here and not the percentageChanged signal so we also get some batching out of it + if (roles.contains(Notifications::PercentageRole)) { + updateApplicationPercentage(job->desktopEntry()); + } + } + + m_pendingDirtyRoles.clear(); + }); + + m_pendingJobViewsTimer->setInterval(500); + m_pendingJobViewsTimer->setSingleShot(true); + connect(m_pendingJobViewsTimer, &QTimer::timeout, this, [this] { + const auto pendingJobs = m_pendingJobViews; + for (Job *job : pendingJobs) { + if (job->state() == Notifications::JobStateStopped) { + // Stop finished or canceled in the meantime, remove + qCDebug(NOTIFICATIONMANAGER) << "By the time we wanted to show JobView" << job->id() << "from" << job->applicationName() << ", it was already stopped"; + remove(job); + continue; + } + + const int newRow = m_jobViews.count(); + emit jobViewAboutToBeAdded(newRow, job); + m_jobViews.append(job); + emit jobViewAdded(newRow, job); + updateApplicationPercentage(job->desktopEntry()); + } + + m_pendingJobViews.clear(); + }); +} + +JobsModelPrivate::~JobsModelPrivate() +{ + QDBusConnection::sessionBus().unregisterService(QStringLiteral("org.kde.JobViewServer")); + QDBusConnection::sessionBus().unregisterService(QStringLiteral("org.kde.kuiserver")); + QDBusConnection::sessionBus().unregisterObject(QStringLiteral("/JobViewServer")); + + qDeleteAll(m_jobViews); + m_jobViews.clear(); + qDeleteAll(m_pendingJobViews); + m_pendingJobViews.clear(); + + m_pendingDirtyRoles.clear(); +} + +bool JobsModelPrivate::init() +{ + if (m_valid) { + return true; + } + + new KuiserverAdaptor(this); + new JobViewServerAdaptor(this); + new JobViewServerV2Adaptor(this); + + QDBusConnection sessionBus = QDBusConnection::sessionBus(); + + if (!sessionBus.registerObject(QStringLiteral("/JobViewServer"), this)) { + qCWarning(NOTIFICATIONMANAGER) << "Failed to register JobViewServer DBus object"; + return false; + } + + if (sessionBus.registerService(QStringLiteral("org.kde.JobViewServer"))) { + qCDebug(NOTIFICATIONMANAGER) << "Registered JobViewServer service on DBus"; + } else { + qCWarning(NOTIFICATIONMANAGER) << "Failed to register JobViewServer service on DBus, is kuiserver running?"; + return false; + } + + if (!sessionBus.registerService(QStringLiteral("org.kde.kuiserver"))) { + qCWarning(NOTIFICATIONMANAGER) << "Failed to register org.kde.kuiserver service on DBus, is kuiserver running?"; + return false; + } + + m_serviceWatcher = new QDBusServiceWatcher(this); + m_serviceWatcher->setConnection(sessionBus); + m_serviceWatcher->setWatchMode(QDBusServiceWatcher::WatchForUnregistration); + connect(m_serviceWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &JobsModelPrivate::onServiceUnregistered); + + m_valid = true; + return true; +} + +void JobsModelPrivate::registerService(const QString &service, const QString &objectPath) +{ + qCWarning(NOTIFICATIONMANAGER) << "Request to register JobView service" << service << "on" << objectPath; + qCWarning(NOTIFICATIONMANAGER) << "org.kde.kuiserver registerService is deprecated and defunct."; + sendErrorReply(QDBusError::NotSupported, QStringLiteral("kuiserver proxying capabilities are deprecated and defunct.")); +} + +QStringList JobsModelPrivate::jobUrls() const +{ + QStringList jobUrls; + for (Job *job : m_jobViews) { + if (job->state() != Notifications::JobStateStopped && job->destUrl().isValid()) { + jobUrls.append(job->destUrl().toString()); + } + } + for (Job *job : m_pendingJobViews) { + if (job->state() != Notifications::JobStateStopped && job->destUrl().isValid()) { + jobUrls.append(job->destUrl().toString()); + } + } + return jobUrls; +} + +void JobsModelPrivate::emitJobUrlsChanged() +{ + emit jobUrlsChanged(jobUrls()); +} + +bool JobsModelPrivate::requiresJobTracker() const +{ + return false; +} + +QStringList JobsModelPrivate::registeredJobContacts() const +{ + return QStringList(); +} + +QDBusObjectPath JobsModelPrivate::requestView(const QString &appName, const QString &appIconName, int capabilities) +{ + QString desktopEntry; + QVariantMap hints; + + QString applicationName = appName; + QString applicationIconName = appIconName; + + // JobViewServerV1 only sends application name, try to look it up as a service + KService::Ptr service = KService::serviceByStorageId(applicationName); + if (!service) { + // HACK :) + service = KService::serviceByStorageId(QStringLiteral("org.kde.") + appName); + } + + if (service) { + desktopEntry = service->desktopEntryName(); + applicationName = service->name(); + applicationIconName = service->icon(); + } + + if (!applicationName.isEmpty()) { + hints.insert(QStringLiteral("application-display-name"), applicationName); + } + if (!applicationIconName.isEmpty()) { + hints.insert(QStringLiteral("application-icon-name"), applicationIconName); + } + + return requestView(desktopEntry, capabilities, hints); +} + +QDBusObjectPath JobsModelPrivate::requestView(const QString &desktopEntry, + int capabilities, + const QVariantMap &hints) +{ + qCDebug(NOTIFICATIONMANAGER) << "JobView requested by" << desktopEntry << "with hints" << hints; + + if (!m_highestJobId) { + ++m_highestJobId; + } + + Job *job = new Job(m_highestJobId); + ++m_highestJobId; + + QString applicationName = hints.value(QStringLiteral("application-display-name")).toString(); + QString applicationIconName = hints.value(QStringLiteral("application-icon-name")).toString(); + + job->setDesktopEntry(desktopEntry); + + KService::Ptr service = KService::serviceByDesktopName(desktopEntry); + if (service) { + if (applicationName.isEmpty()) { + applicationName = service->name(); + } + if (applicationIconName.isEmpty()) { + applicationIconName = service->icon(); + } + } + + job->setApplicationName(applicationName); + job->setApplicationIconName(applicationIconName); + + // No application name? Try to figure out the process name using the sender's PID + const QString serviceName = message().service(); + if (job->applicationName().isEmpty()) { + qCInfo(NOTIFICATIONMANAGER) << "JobView request from" << serviceName << "didn't contain any identification information, this is an application bug!"; + const QString processName = Utils::processNameFromDBusService(connection(), serviceName); + if (!processName.isEmpty()) { + qCDebug(NOTIFICATIONMANAGER) << "Resolved JobView request to be from" << processName; + job->setApplicationName(processName); + } + } + + job->setSuspendable(capabilities & KJob::Suspendable); + job->setKillable(capabilities & KJob::Killable); + + connect(job, &Job::updatedChanged, this, [this, job] { + scheduleUpdate(job, Notifications::UpdatedRole); + }); + connect(job, &Job::summaryChanged, this, [this, job] { + scheduleUpdate(job, Notifications::SummaryRole); + }); + connect(job, &Job::stateChanged, this, [this, job] { + scheduleUpdate(job, Notifications::JobStateRole); + // Timeout and Closable depend on state, signal a change for those, too + scheduleUpdate(job, Notifications::TimeoutRole); + scheduleUpdate(job, Notifications::ClosableRole); + + if (job->state() == Notifications::JobStateStopped) { + updateApplicationPercentage(job->desktopEntry()); + emitJobUrlsChanged(); + } + }); + connect(job, &Job::percentageChanged, this, [this, job] { + scheduleUpdate(job, Notifications::PercentageRole); + }); + connect(job, &Job::errorChanged, this, [this, job] { + scheduleUpdate(job, Notifications::JobErrorRole); + }); + connect(job, &Job::expiredChanged, this, [this, job] { + scheduleUpdate(job, Notifications::ExpiredRole); + }); + connect(job, &Job::dismissedChanged, this, [this, job] { + scheduleUpdate(job, Notifications::DismissedRole); + }); + + // The following are used in generating the pretty job text + connect(job, &Job::processedFilesChanged, this, [this, job] { + scheduleUpdate(job, Notifications::BodyRole); + }); + connect(job, &Job::totalFilesChanged, this, [this, job] { + scheduleUpdate(job, Notifications::BodyRole); + }); + connect(job, &Job::descriptionValue1Changed, this, [this, job] { + scheduleUpdate(job, Notifications::BodyRole); + }); + connect(job, &Job::descriptionValue2Changed, this, [this, job] { + scheduleUpdate(job, Notifications::BodyRole); + }); + connect(job, &Job::destUrlChanged, this, [this, job] { + scheduleUpdate(job, Notifications::BodyRole); + emitJobUrlsChanged(); + }); + connect(job, &Job::errorTextChanged, this, [this, job] { + scheduleUpdate(job, Notifications::BodyRole); + }); + + connect(job->d, &JobPrivate::closed, this, [this, job] { + remove(job); + }); + + // Delay showing a job view by 500ms to avoid showing really short stat jobs and other useless stuff + if (hints.value(QStringLiteral("immediate")).toBool()) { + const int newRow = m_jobViews.count(); + emit jobViewAboutToBeAdded(newRow, job); + m_jobViews.append(job); + emit jobViewAdded(newRow, job); + updateApplicationPercentage(job->desktopEntry()); + } else { + m_pendingJobViews.append(job); + m_pendingJobViewsTimer->start(); + } + + m_jobServices.insert(job, serviceName); + m_serviceWatcher->addWatchedService(serviceName); + + return job->d->objectPath(); +} + +void JobsModelPrivate::remove(Job *job) +{ + const int row = m_jobViews.indexOf(job); + if (row > -1) { + removeAt(row); + } +} + +void JobsModelPrivate::removeAt(int row) +{ + Q_ASSERT(row >= 0 && row < m_jobViews.count()); + + emit jobViewAboutToBeRemoved(row);//, job); + Job *job = m_jobViews.takeAt(row); + m_pendingDirtyRoles.remove(job); + m_pendingJobViews.removeOne(job); + + const QString desktopEntry = job->desktopEntry(); + + const QString serviceName = m_jobServices.take(job); + + // Check if there's any jobs left for this service, otherwise stop watching it + auto it = std::find_if(m_jobServices.constBegin(), m_jobServices.constEnd(), [&serviceName](const QString &item) { + return item == serviceName; + }); + if (it == m_jobServices.constEnd()) { + m_serviceWatcher->removeWatchedService(serviceName); + } + + delete job; + emit jobViewRemoved(row); + + updateApplicationPercentage(desktopEntry); +} + +// This will forward overall application process via Unity API. +// This way users of that like Task Manager and Latte Dock still get basic job information. +void JobsModelPrivate::updateApplicationPercentage(const QString &desktopEntry) +{ + if (desktopEntry.isEmpty()) { + return; + } + + int jobsPercentages = 0; + int jobsCount = 0; + + for (int i = 0; i < m_jobViews.count(); ++i) { + Job *job = m_jobViews.at(i); + if (job->state() == Notifications::JobStateStopped + || job->desktopEntry() != desktopEntry) { + continue; + } + + jobsPercentages += job->percentage(); + ++jobsCount; + } + + int percentage = 0; + if (jobsCount > 0) { + percentage = jobsPercentages / jobsCount; + } + + const QVariantMap properties = { + {QStringLiteral("count-visible"), jobsCount > 0}, + {QStringLiteral("count"), jobsCount}, + {QStringLiteral("progress-visible"), jobsCount > 0}, + {QStringLiteral("progress"), percentage / 100.0} + }; + + QDBusMessage message = QDBusMessage::createSignal(QStringLiteral("/org/kde/notificationmanager/jobs"), + QStringLiteral("com.canonical.Unity.LauncherEntry"), + QStringLiteral("Update")); + message.setArguments({QStringLiteral("application://") + desktopEntry, properties}); + QDBusConnection::sessionBus().send(message); +} + +void JobsModelPrivate::onServiceUnregistered(const QString &serviceName) +{ + qCDebug(NOTIFICATIONMANAGER) << "JobView service unregistered" << serviceName; + + m_serviceWatcher->removeWatchedService(serviceName); + + const QList jobs = m_jobServices.keys(serviceName); + for (Job *job : jobs) { + // Mark all non-finished jobs as failed + if (job->state() == Notifications::JobStateStopped) { + continue; + } + job->setError(127); // KIO::ERR_SLAVE_DIED + job->setErrorText(i18n("Application closed unexpectedly.")); + job->setState(Notifications::JobStateStopped); + } +} + +void JobsModelPrivate::scheduleUpdate(Job *job, Notifications::Roles role) +{ + m_pendingDirtyRoles[job].append(role); + m_compressUpdatesTimer->start(); +} 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 + + + + + + + + + + + + CloseToWidget + + + 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 icon() const; + void setIcon(const QString &icon); + + 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,654 @@ +/* + * 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 + +#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)) { \ + qCWarning(NOTIFICATIONMANAGER) << "Image decoding 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) { + qCWarning(NOTIFICATIONMANAGER) << "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) { + qCWarning(NOTIFICATIONMANAGER) << "Image data is incomplete. y:" << y << "height:" << height; + break; + } + fcn((QRgb*)image.scanLine(y), ptr, width); + } + + return image; +} + +void Notification::Private::sanitizeImage(QImage &image) +{ + if (image.isNull()) { + return; + } + + const QSize max = maximumImageSize(); + if (image.size().width() > max.width() + || image.size().height() > max.height()) { + image = image.scaled(max, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } +} + +void Notification::Private::loadImagePath(const QString &path) +{ + // image_path and appIcon should either be a URL with file scheme or the name of a themed icon. + // We're lenient and also allow local paths. + + image = QImage(); // clear + icon.clear(); + + QUrl imageUrl; + if (path.startsWith(QLatin1Char('/'))) { + imageUrl = QUrl::fromLocalFile(path); + } else if (path.contains(QLatin1Char('/'))) { // bad heuristic to detect a URL + imageUrl = QUrl(path); + + if (!imageUrl.isLocalFile()) { + qCDebug(NOTIFICATIONMANAGER) << "Refused to load image from" << path << "which isn't a valid local location."; + return; + } + } + + if (!imageUrl.isValid()) { + // try icon path instead; + icon = path; + return; + } + + QImageReader reader(imageUrl.toLocalFile()); + reader.setAutoTransform(true); + + const QSize imageSize = reader.size(); + if (imageSize.isValid() && (imageSize.width() > maximumImageSize().width() || imageSize.height() > maximumImageSize().height())) { + const QSize thumbnailSize = imageSize.scaled(maximumImageSize(), Qt::KeepAspectRatio); + reader.setScaledSize(thumbnailSize); + } + + image = reader.read(); +} + +QString Notification::Private::defaultComponentName() +{ + // NOTE Keep in sync with KNotification + return QStringLiteral("plasma_workspace"); +} + +QSize Notification::Private::maximumImageSize() +{ + return QSize(256, 256); +} + +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-appname")).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) { + loadImagePath(it->toString()); + } + } + + sanitizeImage(image); +} + +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::icon() const +{ + return d->icon; +} + +void Notification::setIcon(const QString &icon) +{ + d->loadImagePath(icon); + Private::sanitizeImage(d->image); +} + +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,93 @@ +/* + * 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 void sanitizeImage(QImage &image); + + void loadImagePath(const QString &path); + + static QString defaultComponentName(); + static QSize maximumImageSize(); + + void processHints(const QVariantMap &hints); + + void setUrgency(Notifications::Urgency urgency); + + uint id = 0; + QDateTime created; + QDateTime updated; + + QString summary; + QString body; + // Can be theme icon name or path + QString icon; + 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,220 @@ +/* + * 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, source](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { + if (roles.isEmpty() || roles.contains(Notifications::IsGroupRole)) { + for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { + const QModelIndex sourceIdx = source->index(i, 0); + + if (!sourceIdx.data(Notifications::IsGroupRole).toBool()) { + if (m_expandedGroups.contains(sourceIdx)) { + setGroupExpanded(topLeft, false); + } + } + } + } + }); + } +} + +QVariant NotificationGroupCollapsingProxyModel::data(const QModelIndex &index, int role) const +{ + switch (role) { + case Notifications::IsGroupExpandedRole: { + if (m_limit > 0) { + // so each item in a group knows whether the group is expanded + const QModelIndex sourceIdx = mapToSource(index); + return m_expandedGroups.contains(sourceIdx.parent().isValid() ? sourceIdx.parent() : sourceIdx); + } + return true; + } + case Notifications::ExpandedGroupChildrenCountRole: + return rowCount(index.parent().isValid() ? index.parent() : index); + } + + 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 QSortFilterProxyModel::setData(index, value, role); +} + +int NotificationGroupCollapsingProxyModel::limit() const +{ + return m_limit; +} + +void NotificationGroupCollapsingProxyModel::setLimit(int limit) +{ + if (m_limit != limit) { + m_limit = limit; + invalidateFilter(); + invalidateGroupRoles(); + emit limitChanged(); + } +} + +QDateTime NotificationGroupCollapsingProxyModel::lastRead() const +{ + return m_lastRead; +} + +void NotificationGroupCollapsingProxyModel::setLastRead(const QDateTime &lastRead) +{ + if (m_lastRead != lastRead) { + m_lastRead = lastRead; + invalidateFilter(); + invalidateGroupRoles(); + emit lastReadChanged(); + } +} + +bool NotificationGroupCollapsingProxyModel::expandUnread() const +{ + return m_expandUnread; +} + +void NotificationGroupCollapsingProxyModel::setExpandUnread(bool expand) +{ + if (m_expandUnread != expand) { + m_expandUnread = expand; + invalidateFilter(); + invalidateGroupRoles(); + emit expandUnreadChanged(); + } +} + +void NotificationGroupCollapsingProxyModel::collapseAll() +{ + m_expandedGroups.clear(); + + invalidateFilter(); + invalidateGroupRoles(); +} + +bool NotificationGroupCollapsingProxyModel::setGroupExpanded(const QModelIndex &idx, bool expanded) +{ + if (idx.data(Notifications::IsGroupExpandedRole).toBool() == expanded) { + return false; + } + + QPersistentModelIndex persistentIdx(mapToSource(idx)); + if (expanded) { + m_expandedGroups.append(persistentIdx); + } else { + m_expandedGroups.removeOne(persistentIdx); + } + + invalidateFilter(); + + const QVector dirtyRoles = {Notifications::ExpandedGroupChildrenCountRole, Notifications::IsGroupExpandedRole}; + + emit dataChanged(idx, idx, dirtyRoles); + emit dataChanged(index(0, 0, idx), index(rowCount(idx) - 1, 0, idx), dirtyRoles); + + return true; +} + +void NotificationGroupCollapsingProxyModel::invalidateGroupRoles() +{ + const QVector dirtyRoles = {Notifications::ExpandedGroupChildrenCountRole, Notifications::IsGroupExpandedRole}; + + emit dataChanged(index(0, 0), index(rowCount() - 1, 0), dirtyRoles); + + for (int row = 0; row < rowCount(); ++row) { + const QModelIndex groupIdx = index(row, 0); + emit dataChanged(index(0, 0, groupIdx), index(rowCount(groupIdx) - 1, 0, groupIdx), dirtyRoles); + } +} + +bool NotificationGroupCollapsingProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + if (m_limit > 0 && source_parent.isValid()) { + if (!m_expandedGroups.isEmpty() && m_expandedGroups.contains(source_parent)) { + return true; + } + + if (m_expandUnread && m_lastRead.isValid()) { + const QModelIndex sourceIdx = sourceModel()->index(source_row, 0, source_parent); + QDateTime time = sourceIdx.data(Notifications::UpdatedRole).toDateTime(); + if (!time.isValid()) { + time = sourceIdx.data(Notifications::CreatedRole).toDateTime(); + } + + if (time.isValid() && m_lastRead < time) { + 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,73 @@ +/* + * 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 + +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); + + QDateTime lastRead() const; + void setLastRead(const QDateTime &lastRead); + + bool expandUnread() const; + void setExpandUnread(bool expand); + + void collapseAll(); + +signals: + void limitChanged(); + void lastReadChanged(); + void expandUnreadChanged(); + +protected: + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + +private: + bool setGroupExpanded(const QModelIndex &idx, bool expanded); + + void invalidateGroupRoles(); + + int m_limit; + QDateTime m_lastRead; + bool m_expandUnread = false; + + 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,524 @@ +/* + * 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.isEmpty() && aName == bName + && !aDesktopEntry.isEmpty() && 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 = index(i, 0, proxyIndex).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 = index(i, 0, proxyIndex).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 (index(i, 0, proxyIndex).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 (index(i, 0, proxyIndex).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,525 @@ +/* + * 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 notifications. + * + * Default is true. + */ + Q_PROPERTY(bool showNotifications READ showNotifications WRITE setShowNotifications NOTIFY showNotificationsChanged) + + /** + * Whether to show application jobs. + * + * Default is false. + */ + 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) + + /** + * Whether to automatically show notifications that are unread. + * + * This is any notification that was created or updated after the value of @c lastRead. + */ + Q_PROPERTY(bool expandUnread READ expandUnread WRITE setExpandUnread NOTIFY expandUnreadChanged) + + /** + * 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. + ExpandedGroupChildrenCountRole, ///< The number of children in a group that are expanded. + 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 name, which is not the application icon. Only valid for icon names, if a URL supplied, it is loaded and exposed as ImageRole instead. + + 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. + JobErrorRole, ///< The error id of the job, zero 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 Job item itself 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. + }; + Q_ENUM(Roles) + + /** + * 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 showNotifications() const; + void setShowNotifications(bool showNotifications); + + 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); + + bool expandUnread() const; + void setExpandUnread(bool expand); + + 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; + + /** + * Convert the given QModelIndex into a QPersistentModelIndex + */ + Q_INVOKABLE QPersistentModelIndex makePersistentModelIndex(const QModelIndex &idx) 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); + + /** + * Returns a model index pointing to the group of a notification. + */ + Q_INVOKABLE QModelIndex groupIndex(const QModelIndex &idx) const; + + Q_INVOKABLE void collapseAllGroups(); + + 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; + 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 showNotificationsChanged(); + void showJobsChanged(); + void urgenciesChanged(); + void sortModeChanged(); + void groupModeChanged(); + void groupLimitChanged(); + void expandUnreadChanged(); + 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,850 @@ +/* + * 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 +#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 "utils_p.h" + +#include "debug.h" + +using namespace NotificationManager; + +class Q_DECL_HIDDEN Notifications::Private +{ +public: + explicit Private(Notifications *q); + ~Private(); + + void initSourceModels(); + void initProxyModels(); + + void updateCount(); + + bool showNotifications = true; + bool showJobs = false; + + Notifications::GroupMode groupMode = Notifications::GroupDisabled; + int groupLimit = 0; + bool expandUnread = false; + + 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); + QModelIndex mapFromModel(const QModelIndex &idx) const; + + // NOTE when you add or re-arrange models make sure to update mapFromModel()! + 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::initSourceModels() +{ + Q_ASSERT(notificationsAndJobsModel); // initProxyModels must be called before initSourceModels + + if (showNotifications && !notificationsModel) { + notificationsModel = NotificationsModel::createNotificationsModel(); + connect(notificationsModel.data(), &NotificationsModel::lastReadChanged, q, [this] { + updateCount(); + emit q->lastReadChanged(); + }); + notificationsAndJobsModel->addSourceModel(notificationsModel.data()); + } else if (!showNotifications && notificationsModel) { + notificationsAndJobsModel->removeSourceModel(notificationsModel.data()); + disconnect(notificationsModel.data(), nullptr, q, nullptr); // disconnect all + notificationsModel = nullptr; + } + + if (showJobs && !jobsModel) { + jobsModel = JobsModel::createJobsModel(); + notificationsAndJobsModel->addSourceModel(jobsModel.data()); + jobsModel->init(); + } else if (!showJobs && jobsModel) { + notificationsAndJobsModel->removeSourceModel(jobsModel.data()); + jobsModel = nullptr; + } +} + +void Notifications::Private::initProxyModels() +{ + /* The data flow is as follows: + * NOTE when you add or re-arrange models make sure to update mapFromModel()! + * + * 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 (!notificationsAndJobsModel) { + notificationsAndJobsModel = new KConcatenateRowsProxyModel(q); + } + + 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->setExpandUnread(expandUnread); + groupCollapsingModel->setLastRead(q->lastRead()); + 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(); + } + + // TODO Jobs could also be unread? + if (notificationsModel) { + 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(); +} + +QModelIndex Notifications::Private::mapFromModel(const QModelIndex &idx) const +{ + QModelIndex resolvedIdx = idx; + + QAbstractItemModel *models[] = { + notificationsAndJobsModel, + filterModel, + sortModel, + groupingModel, + groupCollapsingModel, + flattenModel, + limiterModel, + }; + + // TODO can we do this with a generic loop like mapFromModel + while (resolvedIdx.isValid() && resolvedIdx.model() != q) { + const auto *idxModel = resolvedIdx.model(); + + // HACK try to find the model that uses the index' model as source + bool found = false; + for (QAbstractItemModel *model : models) { + if (!model) { + continue; + } + + if (auto *proxyModel = qobject_cast(model)) { + if (proxyModel->sourceModel() == idxModel) { + resolvedIdx = proxyModel->mapFromSource(resolvedIdx); + found = true; + break; + } + } else if (auto *concatenateModel = qobject_cast(model)) { + // There's no "sourceModels()" on KConcatenateRowsProxyModel + if (idxModel == notificationsModel.data() || idxModel == jobsModel.data()) { + resolvedIdx = concatenateModel->mapFromSource(resolvedIdx); + found = true; + break; + } + } + } + + if (!found) { + break; + } + } + 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)) +{ + // The proxy models are always the same, just with different + // properties set whereas we want to avoid loading a source model + // e.g. notifications or jobs when we're not actually using them + d->initProxyModels(); + + // init source models when used from C++ + QMetaObject::invokeMethod(this, [this] { + d->initSourceModels(); + }, Qt::QueuedConnection); +} + +Notifications::~Notifications() = default; + +void Notifications::classBegin() +{ + +} + +void Notifications::componentComplete() +{ + // init source models when used from QML + d->initSourceModels(); +} + +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::expandUnread() const +{ + return d->expandUnread; +} + +void Notifications::setExpandUnread(bool expand) +{ + if (d->expandUnread == expand) { + return; + } + + d->expandUnread = expand; + if (d->groupCollapsingModel) { + d->groupCollapsingModel->setExpandUnread(expand); + } + emit expandUnreadChanged(); +} + +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::showNotifications() const +{ + return d->showNotifications; +} + +void Notifications::setShowNotifications(bool show) +{ + if (d->showNotifications == show) { + return; + } + + d->showNotifications = show; + d->initSourceModels(); + emit showNotificationsChanged(); +} + +bool Notifications::showJobs() const +{ + return d->showJobs; +} + +void Notifications::setShowJobs(bool show) +{ + if (d->showJobs == show) { + return; + } + + d->showJobs = show; + d->initSourceModels(); + emit showJobsChanged(); +} + +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->initProxyModels(); + 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 +{ + if (d->notificationsModel) { + return d->notificationsModel->lastRead(); + } + return QDateTime(); +} + +void Notifications::setLastRead(const QDateTime &lastRead) +{ + // TODO jobs could also be unread? + if (d->notificationsModel) { + d->notificationsModel->setLastRead(lastRead); + } + if (d->groupCollapsingModel) { + d->groupCollapsingModel->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; +} + +QPersistentModelIndex Notifications::makePersistentModelIndex(const QModelIndex &idx) const +{ + return QPersistentModelIndex(idx); +} + +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(Utils::mapToModel(idx, d->jobsModel.data())); + break; + default: + Q_UNREACHABLE(); + } +} + +void Notifications::close(const QModelIndex &idx) +{ + if (idx.data(Notifications::IsGroupRole).toBool()) { + const QModelIndex groupIdx = Utils::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(Utils::mapToModel(idx, d->jobsModel.data())); + break; + default: + Q_UNREACHABLE(); + } +} + +void Notifications::configure(const QModelIndex &idx) +{ + if (!d->notificationsModel) { + return; + } + + // 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) +{ + if (d->notificationsModel) { + d->notificationsModel->invokeDefaultAction(Private::notificationId(idx)); + } +} + +void Notifications::invokeAction(const QModelIndex &idx, const QString &actionId) +{ + if (d->notificationsModel) { + d->notificationsModel->invokeAction(Private::notificationId(idx), actionId); + } +} + +void Notifications::startTimeout(const QModelIndex &idx) +{ + startTimeout(Private::notificationId(idx)); +} + +void Notifications::startTimeout(uint notificationId) +{ + if (d->notificationsModel) { + d->notificationsModel->startTimeout(notificationId); + } +} + +void Notifications::stopTimeout(const QModelIndex &idx) +{ + if (d->notificationsModel) { + d->notificationsModel->stopTimeout(Private::notificationId(idx)); + } +} + +void Notifications::suspendJob(const QModelIndex &idx) +{ + if (d->jobsModel) { + d->jobsModel->suspend(Utils::mapToModel(idx, d->jobsModel.data())); + } +} + +void Notifications::resumeJob(const QModelIndex &idx) +{ + if (d->jobsModel) { + d->jobsModel->resume(Utils::mapToModel(idx, d->jobsModel.data())); + } +} + +void Notifications::killJob(const QModelIndex &idx) +{ + if (d->jobsModel) { + d->jobsModel->kill(Utils::mapToModel(idx, d->jobsModel.data())); + } +} + +void Notifications::clear(ClearFlags flags) +{ + if (d->notificationsModel) { + d->notificationsModel->clear(flags); + } + if (d->jobsModel) { + d->jobsModel->clear(flags); + } +} + +QModelIndex Notifications::groupIndex(const QModelIndex &idx) const +{ + if (idx.data(Notifications::IsGroupRole).toBool()) { + return idx; + } + + if (idx.data(Notifications::IsInGroupRole).toBool()) { + QModelIndex groupingIdx = Utils::mapToModel(idx, d->groupingModel); + return d->mapFromModel(groupingIdx.parent()); + } + + qCWarning(NOTIFICATIONMANAGER) << "Cannot get group index for item that isn't a group or inside one"; + return QModelIndex(); +} + +void Notifications::collapseAllGroups() +{ + if (d->groupCollapsingModel) { + d->groupCollapsingModel->collapseAll(); + } +} + +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 +{ + static QHash s_roles; + + if (s_roles.isEmpty()) { + s_roles = QSortFilterProxyModel::roleNames(); + + // This generates role names from the Roles enum in the form of: FooRole -> foo + const QMetaEnum e = staticMetaObject.enumerator(staticMetaObject.indexOfEnumerator("Roles")); + + for (int i = 0; i < e.keyCount(); ++i) { + const int value = e.value(i); + + QByteArray key(e.key(i)); + key[0] = key[0] + 32; // lower case first letter + key.chop(4); // strip "Role" suffix + + s_roles.insert(value, key); + } + + s_roles.insert(IdRole, QByteArrayLiteral("notificationId")); // id is QML-reserved + } + + return s_roles; +} 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,489 @@ +/* + * 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 + +static const int s_notificationsLimit = 1000; + +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; + } + } + + // Once we reach a certain insane number of notifications discard some old ones + // as we keep pixmaps around etc + if (notifications.count() >= s_notificationsLimit) { + const int cleanupCount = s_notificationsLimit / 2; + qCDebug(NOTIFICATIONMANAGER) << "Reached the notification limit of" << s_notificationsLimit << ", discarding the oldest" << cleanupCount << "notifications"; + q->beginRemoveRows(QModelIndex(), 0, cleanupCount - 1); + for (int i = 0 ; i < cleanupCount; ++i) { + notifications.removeAt(0); + // TODO close gracefully? + } + q->endRemoveRows(); + } + + setupNotificationTimeout(notification); + + q->beginInsertRows(QModelIndex(), notifications.count(), notifications.count()); + notifications.append(std::move(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 (!checkIndex(index)) { + 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: + if (notification.image().isNull()) { + return notification.icon(); + } + break; + 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,7 @@ +# Screenshot notifications are explicit user interactions, always show them +[Applications][org.kde.spectacle] +ShowPopupsInDndMode=true + +# Defaults for media players so their track change notifications don't accumulate in the history +[Applications][vlc] +ShowInHistory=false diff --git a/libnotificationmanager/server.h b/libnotificationmanager/server.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/server.h @@ -0,0 +1,154 @@ +/* + * 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 "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? + + 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,109 @@ +/* + * 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 + +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,101 @@ +/* + * 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); + 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 = 1; + +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,346 @@ +/* + * 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 "utils_p.h" + +#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 { + // Avoid wrapping around to 0 in case of overflow + if (!m_highestNotificationId) { + ++m_highestNotificationId; + } + notificationId = m_highestNotificationId; + ++m_highestNotificationId; + } + + Notification notification(notificationId); + notification.setSummary(summary); + notification.setBody(body); + 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); + + // If we didn't get a pixmap, load the app_icon instead + if (notification.d->image.isNull()) { + notification.setIcon(app_icon); + } + + // 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!"; + + const QString processName = Utils::processNameFromDBusService(connection(), message().service()); + if (!processName.isEmpty()) { + qCDebug(NOTIFICATIONMANAGER) << "Resolved notification to be from" << processName; + notification.setApplicationName(processName); + } + } + + 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"), + + QStringLiteral("x-kde-urls"), + QStringLiteral("x-kde-device-name"), + QStringLiteral("x-kde-display-appname"), + + 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::inhibitions() const +{ + return m_inhibitions.values(); +} + +bool ServerPrivate::inhibited() const +{ + // TODO this currently only returns whether an app has an inhibition going, + // there's no way for apps to query whether user enabled do not disturb from the applet + // so they could change their behavior. + 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,325 @@ +/* + * 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. + * CloseToWidget 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 + * stream but only remembers what value was assigned to this property. + * + * This way you can tell whether to unmute notification sounds or not, in case + * the user had them explicitly muted previously. + * + * @note This does not actually mute or unmute the actual sound stream, + * you need to do this yourself using e.g. PulseAudio. + */ + Q_PROPERTY(bool notificationSoundsInhibited + READ notificationSoundsInhibited + WRITE setNotificationSoundsInhibited + NOTIFY settingsChanged) + + /** + * 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 { + CloseToWidget = 0, + 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); + + /** + * 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(); + +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,557 @@ +/* + * 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 + +#include +#include + +#include "server.h" +#include "debug.h" + +// Settings +#include "donotdisturbsettings.h" +#include "notificationsettings.h" +#include "jobsettings.h" +#include "badgesettings.h" + +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; + +}; + +Settings::Private::Private(Settings *q) + : q(q) +{ + +} + +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() +{ + d->config->markAsClean(); +} + +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() +{ + d->config->markAsClean(); + d->config->reparseConfiguration(); + 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 +{ + return DoNotDisturbSettings::notificationSoundsMuted(); +} + +void Settings::setNotificationSoundsInhibited(bool inhibited) +{ + if (inhibited == notificationSoundsInhibited()) { + return; + } + + DoNotDisturbSettings::setNotificationSoundsMuted(inhibited); + d->setDirty(true); +} diff --git a/libnotificationmanager/utils.cpp b/libnotificationmanager/utils.cpp new file mode 100644 --- /dev/null +++ b/libnotificationmanager/utils.cpp @@ -0,0 +1,76 @@ +/* + * 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 "utils_p.h" + +#include +#include +#include +#include + +#include + +#include + +#include +#include + +using namespace NotificationManager; + +QString Utils::processNameFromDBusService(const QDBusConnection &connection, const QString &serviceName) +{ + QDBusReply pidReply = connection.interface()->servicePid(serviceName); + if (!pidReply.isValid()) { + return QString(); + } + + const auto pid = pidReply.value(); + + KSysGuard::Processes procs; + procs.updateOrAddProcess(pid); + + KSysGuard::Process *proc = procs.getProcess(pid); + + if (!proc) { + return QString(); + } + + return proc->name(); +} + +QModelIndex Utils::mapToModel(const QModelIndex &idx, const QAbstractItemModel *sourceModel) +{ + // KModelIndexProxyMapper can only map diferent indices to a single source + // but we have the other way round, a single index that splits into different source models + QModelIndex resolvedIdx = idx; + while (resolvedIdx.isValid() && resolvedIdx.model() != sourceModel) { + if (auto *proxyModel = qobject_cast(resolvedIdx.model())) { + resolvedIdx = proxyModel->mapToSource(resolvedIdx); + // KConcatenateRowsProxyModel isn't a "real" proxy model, so we need to special case for it :( + } else if (auto *concatenateModel = qobject_cast(resolvedIdx.model())) { + resolvedIdx = concatenateModel->mapToSource(resolvedIdx); + } else { + if (resolvedIdx.model() != sourceModel) { + resolvedIdx = QModelIndex(); // give up + } + } + } + return resolvedIdx; +} diff --git a/libnotificationmanager/utils_p.h b/libnotificationmanager/utils_p.h new file mode 100644 --- /dev/null +++ b/libnotificationmanager/utils_p.h @@ -0,0 +1,42 @@ +/* + * 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 + +class QAbstractItemModel; +class QDBusConnection; + +namespace NotificationManager +{ + +namespace Utils +{ + +QString processNameFromDBusService(const QDBusConnection &connection, + const QString &serviceName); + +QModelIndex mapToModel(const QModelIndex &idx, const QAbstractItemModel *sourceModel); + +} // namespace Utils + +} // namespace NotificationManager