feat(application): add a notification class and post Bluetooth notices through it
parent
ca5a1d3319
commit
506e9e84b1
@ -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(¬ification, &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(¬ification, &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(¬ification, &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"
|
||||
Loading…
Reference in New Issue