feat(application): add a notification class and post Bluetooth notices through it

pull/18/head
reionwong 3 weeks ago
parent ca5a1d3319
commit 506e9e84b1

@ -27,6 +27,8 @@ add_library(cutefish-framework-application SHARED
keyfile.h
mimeapps.cpp
mimeapps.h
cutefishnotification.cpp
cutefishnotification.h
preferences.cpp
preferences.h
proxysettings.cpp
@ -76,6 +78,7 @@ install(FILES
languageenvironment.h
keyfile.h
mimeapps.h
cutefishnotification.h
preferences.h
proxysettings.h
applicationlauncher.h
@ -122,6 +125,11 @@ if(BUILD_TESTING)
target_link_libraries(tst_mimeapps PRIVATE Cutefish::Application Qt6::Test)
add_test(NAME mimeapps COMMAND tst_mimeapps)
find_program(DBUS_RUN_SESSION dbus-run-session REQUIRED)
add_executable(tst_notification tests/tst_notification.cpp)
target_link_libraries(tst_notification PRIVATE Cutefish::Application Qt6::Test)
add_test(NAME notification COMMAND ${DBUS_RUN_SESSION} -- $<TARGET_FILE:tst_notification>)
add_executable(tst_applicationregistry tests/tst_applicationregistry.cpp)
target_link_libraries(tst_applicationregistry PRIVATE Cutefish::Application Qt6::Test)
add_test(NAME applicationregistry COMMAND tst_applicationregistry)

@ -0,0 +1,202 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "cutefishnotification.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QVariantMap>
namespace {
const QString kService = QStringLiteral("org.freedesktop.Notifications");
const QString kPath = QStringLiteral("/org/freedesktop/Notifications");
}
namespace Cutefish {
Notification::Notification(QObject *parent)
: QObject(parent)
{
}
void Notification::setDesktopEntry(const QString &id)
{
m_desktopEntry = id;
}
void Notification::setAppName(const QString &name)
{
m_appName = name;
}
void Notification::setIconName(const QString &name)
{
m_iconName = name;
}
void Notification::setImagePath(const QString &path)
{
m_imagePath = path;
}
void Notification::setSummary(const QString &summary)
{
m_summary = summary;
}
void Notification::setBody(const QString &body)
{
m_body = body;
}
void Notification::setUrgency(Urgency urgency)
{
m_urgency = urgency;
}
void Notification::setTimeout(int timeout)
{
m_timeout = timeout;
}
void Notification::addAction(const QString &key, const QString &label)
{
m_actions << key << label;
}
void Notification::clearActions()
{
m_actions.clear();
}
void Notification::setAutoDelete(bool autoDelete)
{
m_autoDelete = autoDelete;
}
void Notification::show()
{
m_closeWhenShown = false;
// The reply brings the id the new content has to replace.
if (m_pending) {
m_showAgain = true;
return;
}
send();
}
void Notification::close()
{
m_showAgain = false;
if (m_pending) {
m_closeWhenShown = true;
return;
}
sendClose();
if (m_autoDelete)
deleteLater();
}
void Notification::onActionInvoked(uint id, const QString &key)
{
if (id != 0 && id == m_id)
Q_EMIT actionInvoked(key);
}
void Notification::onNotificationClosed(uint id, uint reason)
{
Q_UNUSED(reason);
if (id == 0 || id != m_id)
return;
// A later show() is a new notification.
m_id = 0;
Q_EMIT closed();
if (m_autoDelete)
deleteLater();
}
void Notification::send()
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (!m_connected) {
bus.connect(kService, kPath, kService, QStringLiteral("ActionInvoked"), this,
SLOT(onActionInvoked(uint, QString)));
bus.connect(kService, kPath, kService, QStringLiteral("NotificationClosed"), this,
SLOT(onNotificationClosed(uint, uint)));
m_connected = true;
}
QVariantMap hints;
hints.insert(QStringLiteral("urgency"), QVariant::fromValue<uchar>(m_urgency));
if (!m_desktopEntry.isEmpty())
hints.insert(QStringLiteral("desktop-entry"), m_desktopEntry);
if (!m_imagePath.isEmpty())
hints.insert(QStringLiteral("image-path"), m_imagePath);
QDBusMessage message = QDBusMessage::createMethodCall(kService, kPath, kService,
QStringLiteral("Notify"));
// Only a running notification server shows it; none is started for it.
message.setAutoStartService(false);
message << m_appName << m_id << m_iconName << m_summary << m_body << m_actions << hints
<< m_timeout;
m_pending = true;
auto *watcher = new QDBusPendingCallWatcher(bus.asyncCall(message), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *watcher) {
watcher->deleteLater();
m_pending = false;
const QDBusPendingReply<uint> reply = *watcher;
if (reply.isError()) {
m_id = 0;
m_showAgain = false;
m_closeWhenShown = false;
Q_EMIT failed();
if (m_autoDelete)
deleteLater();
return;
}
// 0: the server dropped it, as the shell does with a repeat within a second.
m_id = reply.value();
if (m_closeWhenShown) {
m_closeWhenShown = false;
close();
} else if (m_showAgain) {
m_showAgain = false;
send();
} else if (m_autoDelete && (m_actions.isEmpty() || m_id == 0)) {
deleteLater();
}
});
}
void Notification::sendClose()
{
if (m_id == 0)
return;
QDBusMessage message = QDBusMessage::createMethodCall(kService, kPath, kService,
QStringLiteral("CloseNotification"));
message.setAutoStartService(false);
message << m_id;
QDBusConnection::sessionBus().asyncCall(message);
m_id = 0;
}
} // namespace Cutefish

@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef CUTEFISH_CUTEFISHNOTIFICATION_H
#define CUTEFISH_CUTEFISHNOTIFICATION_H
#include <QObject>
#include <QString>
#include <QStringList>
namespace Cutefish {
// One notification on the session's notification server; show() again replaces it.
class Notification : public QObject
{
Q_OBJECT
public:
enum Urgency { Low, Normal, Critical };
Q_ENUM(Urgency)
explicit Notification(QObject *parent = nullptr);
// The shell titles a notification with its desktop entry's name and files it
// under that application; an app name is only for senders without an entry.
void setDesktopEntry(const QString &id);
void setAppName(const QString &name);
void setIconName(const QString &name);
void setImagePath(const QString &path);
void setSummary(const QString &summary);
void setBody(const QString &body);
// Critical stays until it is dismissed.
void setUrgency(Urgency urgency);
// In milliseconds; -1 leaves it to the server, 0 keeps it until dismissed.
void setTimeout(int timeout);
// "default" is a click on the notification itself and gets no button.
void addAction(const QString &key, const QString &label = QString());
void clearActions();
// Deleted once closed, or once shown when it has no actions to report.
void setAutoDelete(bool autoDelete);
void show();
void close();
Q_SIGNALS:
void actionInvoked(const QString &key);
// Dismissed, expired or revoked by the server; close() does not emit it.
void closed();
// No notification server answered.
void failed();
private Q_SLOTS:
void onActionInvoked(uint id, const QString &key);
void onNotificationClosed(uint id, uint reason);
private:
void send();
void sendClose();
QString m_desktopEntry;
QString m_appName;
QString m_iconName;
QString m_imagePath;
QString m_summary;
QString m_body;
QStringList m_actions;
Urgency m_urgency = Normal;
int m_timeout = -1;
bool m_autoDelete = false;
bool m_connected = false;
// The server's id; a Notify in flight has none yet.
uint m_id = 0;
bool m_pending = false;
bool m_showAgain = false;
bool m_closeWhenShown = false;
};
} // namespace Cutefish
#endif // CUTEFISH_CUTEFISHNOTIFICATION_H

@ -0,0 +1,264 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "cutefishnotification.h"
#include <QDBusConnection>
#include <QDBusContext>
#include <QDBusMessage>
#include <QPointer>
#include <QSignalSpy>
#include <QtTest>
using Cutefish::Notification;
namespace {
const QString kService = QStringLiteral("org.freedesktop.Notifications");
const QString kPath = QStringLiteral("/org/freedesktop/Notifications");
// The fake lives on a connection of its own, so every call crosses the bus.
QDBusConnection fakeBus()
{
return QDBusConnection::connectToBus(QDBusConnection::SessionBus, QStringLiteral("fake"));
}
struct Call {
QString appName;
uint replacesId;
QString icon;
QString summary;
QString body;
QStringList actions;
QVariantMap hints;
int timeout;
};
}
class FakeServer : public QObject, protected QDBusContext
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.freedesktop.Notifications")
public:
QList<Call> calls;
QList<uint> closed;
bool fails = false;
bool holdsReplies = false;
QList<QDBusMessage> held;
uint nextId = 1;
void releaseReplies()
{
const QList<QDBusMessage> messages = std::exchange(held, {});
for (const QDBusMessage &message : messages)
fakeBus().send(message);
}
void emitSignal(const QString &name, const QVariantList &arguments)
{
QDBusMessage signal = QDBusMessage::createSignal(kPath, kService, name);
signal.setArguments(arguments);
fakeBus().send(signal);
}
public slots:
uint Notify(const QString &appName, uint replacesId, const QString &icon,
const QString &summary, const QString &body, const QStringList &actions,
const QVariantMap &hints, int timeout)
{
if (fails) {
sendErrorReply(QDBusError::ServiceUnknown, QStringLiteral("No server"));
return 0;
}
calls.append({appName, replacesId, icon, summary, body, actions, hints, timeout});
const uint id = replacesId ? replacesId : nextId++;
if (holdsReplies) {
setDelayedReply(true);
held.append(message().createReply(id));
}
return id;
}
void CloseNotification(uint id) { closed.append(id); }
};
class NotificationTest : public QObject
{
Q_OBJECT
private:
FakeServer m_server;
// Lets a call that should not happen reach the fake first.
static void settle() { QTest::qWait(150); }
private slots:
void initTestCase()
{
QDBusConnection bus = fakeBus();
QVERIFY(bus.isConnected());
QVERIFY(bus.registerObject(kPath, &m_server, QDBusConnection::ExportAllSlots));
QVERIFY(bus.registerService(kService));
}
void init()
{
m_server.calls.clear();
m_server.closed.clear();
m_server.fails = false;
m_server.holdsReplies = false;
m_server.held.clear();
m_server.nextId = 1;
}
void sendsTheFields()
{
Notification notification;
notification.setDesktopEntry(QStringLiteral("cutefish-settings"));
notification.setIconName(QStringLiteral("battery-caution"));
notification.setImagePath(QStringLiteral("/tmp/shot.png"));
notification.setSummary(QStringLiteral("Summary"));
notification.setBody(QStringLiteral("Body"));
notification.setUrgency(Notification::Critical);
notification.setTimeout(5000);
notification.addAction(QStringLiteral("default"));
notification.addAction(QStringLiteral("open"), QStringLiteral("Open"));
notification.show();
QTRY_COMPARE(m_server.calls.size(), 1);
const Call call = m_server.calls.at(0);
QCOMPARE(call.appName, QString());
QCOMPARE(call.replacesId, 0u);
QCOMPARE(call.icon, QStringLiteral("battery-caution"));
QCOMPARE(call.summary, QStringLiteral("Summary"));
QCOMPARE(call.body, QStringLiteral("Body"));
QCOMPARE(call.actions, (QStringList {"default", "", "open", "Open"}));
QCOMPARE(call.hints.value("desktop-entry").toString(), QStringLiteral("cutefish-settings"));
QCOMPARE(call.hints.value("image-path").toString(), QStringLiteral("/tmp/shot.png"));
QCOMPARE(call.hints.value("urgency").typeId(), QMetaType::UChar);
QCOMPARE(call.hints.value("urgency").toInt(), 2);
QCOMPARE(call.timeout, 5000);
}
void showAgainReplaces()
{
Notification notification;
notification.setSummary(QStringLiteral("First"));
notification.show();
QTRY_COMPARE(m_server.calls.size(), 1);
settle();
notification.setSummary(QStringLiteral("Second"));
notification.show();
QTRY_COMPARE(m_server.calls.size(), 2);
QCOMPARE(m_server.calls.at(1).replacesId, 1u);
QCOMPARE(m_server.calls.at(1).summary, QStringLiteral("Second"));
}
void showWhilePendingWaitsForTheId()
{
m_server.holdsReplies = true;
Notification notification;
notification.setSummary(QStringLiteral("First"));
notification.show();
notification.setSummary(QStringLiteral("Second"));
notification.show();
notification.setSummary(QStringLiteral("Third"));
notification.show();
QTRY_COMPARE(m_server.calls.size(), 1);
m_server.holdsReplies = false;
m_server.releaseReplies();
QTRY_COMPARE(m_server.calls.size(), 2);
QCOMPARE(m_server.calls.at(1).replacesId, 1u);
QCOMPARE(m_server.calls.at(1).summary, QStringLiteral("Third"));
settle();
QCOMPARE(m_server.calls.size(), 2);
}
void closeWhilePendingClosesOnceShown()
{
m_server.holdsReplies = true;
Notification notification;
notification.show();
notification.close();
QTRY_COMPARE(m_server.calls.size(), 1);
m_server.releaseReplies();
QTRY_COMPARE(m_server.closed, QList<uint> {1});
}
void closedByTheServer()
{
Notification notification;
QSignalSpy closed(&notification, &Notification::closed);
notification.show();
QTRY_COMPARE(m_server.calls.size(), 1);
settle();
m_server.emitSignal(QStringLiteral("NotificationClosed"), {2u, 2u});
settle();
QCOMPARE(closed.size(), 0);
m_server.emitSignal(QStringLiteral("NotificationClosed"), {1u, 2u});
QTRY_COMPARE(closed.size(), 1);
notification.show();
QTRY_COMPARE(m_server.calls.size(), 2);
QCOMPARE(m_server.calls.at(1).replacesId, 0u);
}
void actionsOfItsOwn()
{
Notification notification;
notification.addAction(QStringLiteral("open"), QStringLiteral("Open"));
QSignalSpy invoked(&notification, &Notification::actionInvoked);
notification.show();
QTRY_COMPARE(m_server.calls.size(), 1);
settle();
m_server.emitSignal(QStringLiteral("ActionInvoked"), {2u, QStringLiteral("open")});
m_server.emitSignal(QStringLiteral("ActionInvoked"), {1u, QStringLiteral("open")});
QTRY_COMPARE(invoked.size(), 1);
QCOMPARE(invoked.at(0).at(0).toString(), QStringLiteral("open"));
}
void failsWithoutAServer()
{
m_server.fails = true;
Notification notification;
QSignalSpy failed(&notification, &Notification::failed);
notification.show();
QTRY_COMPARE(failed.size(), 1);
}
void autoDeleteWithoutActions()
{
QPointer<Notification> notification = new Notification;
notification->setAutoDelete(true);
notification->show();
QTRY_VERIFY(notification.isNull());
QCOMPARE(m_server.calls.size(), 1);
}
void autoDeleteWaitsForTheClose()
{
QPointer<Notification> notification = new Notification;
notification->setAutoDelete(true);
notification->addAction(QStringLiteral("default"));
notification->show();
QTRY_COMPARE(m_server.calls.size(), 1);
settle();
QVERIFY(!notification.isNull());
m_server.emitSignal(QStringLiteral("NotificationClosed"), {1u, 1u});
QTRY_VERIFY(notification.isNull());
}
};
QTEST_GUILESS_MAIN(NotificationTest)
#include "tst_notification.moc"

@ -29,10 +29,13 @@ qt_add_qml_module(cutefish-framework-bluetooth
add_library(Cutefish::Bluetooth ALIAS cutefish-framework-bluetooth)
target_link_libraries(cutefish-framework-bluetooth PUBLIC
Qt6::Core
Qt6::Qml
Qt6::DBus
target_link_libraries(cutefish-framework-bluetooth
PUBLIC
Qt6::Core
Qt6::Qml
Qt6::DBus
PRIVATE
Cutefish::Application
)
cutefish_install_qml_module(cutefish-framework-bluetooth)

@ -1,6 +1,7 @@
#include "bluetoothmanager.h"
#include "bluetoothagent.h"
#include "bluetoothrfkill.h"
#include "cutefishnotification.h"
#include <QDBusArgument>
#include <QDBusPendingCallWatcher>
@ -804,13 +805,14 @@ QString BluetoothManager::describe(const QDBusMessage &reply) const
void BluetoothManager::notify(const QString &summary, const QString &body)
{
const QString service = QStringLiteral("org.freedesktop.Notifications");
const QString path = QStringLiteral("/org/freedesktop/Notifications");
QDBusMessage message =
QDBusMessage::createMethodCall(service, path, service, QStringLiteral("Notify"));
message << tr("Bluetooth") << 0u << QStringLiteral("bluetooth") << summary << body
<< QStringList() << QVariantMap() << -1;
QDBusConnection::sessionBus().call(message, QDBus::NoBlock);
auto *notification = new Cutefish::Notification(this);
// Sent from whichever process shows Bluetooth, so no desktop entry of its own.
notification->setAppName(tr("Bluetooth"));
notification->setIconName(QStringLiteral("bluetooth"));
notification->setSummary(summary);
notification->setBody(body);
notification->setAutoDelete(true);
notification->show();
}
void BluetoothManager::clearError()

@ -8,6 +8,9 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
endif()
find_package(Qt6 REQUIRED COMPONENTS Core DBus Qml Test)
if(NOT TARGET Cutefish::Application)
find_package(CutefishFramework REQUIRED COMPONENTS Application)
endif()
find_program(DBUS_RUN_SESSION dbus-run-session REQUIRED)
add_executable(tst_bluetooth tst_bluetooth.cpp
../bluetoothmanager.cpp ../bluetoothmanager.h
@ -17,7 +20,8 @@ add_executable(tst_bluetooth tst_bluetooth.cpp
../bluetoothrfkill.cpp ../bluetoothrfkill.h
../bluetoothdbustypes.cpp ../bluetoothdbustypes.h
)
target_link_libraries(tst_bluetooth PRIVATE Qt6::Core Qt6::DBus Qt6::Qml Qt6::Test)
target_link_libraries(tst_bluetooth PRIVATE Cutefish::Application Qt6::Core Qt6::DBus Qt6::Qml
Qt6::Test)
add_test(NAME bluetooth COMMAND ${DBUS_RUN_SESSION} -- $<TARGET_FILE:tst_bluetooth>)
set_tests_properties(bluetooth PROPERTIES TIMEOUT 60)

Loading…
Cancel
Save