Files
z-bar-qt/Plugins/ZShell/Services/notificationstore.cpp
T
zach 11e7aac195
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Failing after 1m26s
Rust / fmt (pull_request) Successful in 46s
Rust / clippy (pull_request) Successful in 1m6s
feat: better notification storage and image caching + blobs
2026-11-02 19:00:43 +01:00

2058 lines
53 KiB
C++

#include "notificationstore.hpp"
#include "config/notifs.hpp"
#include "config/rootnodes.hpp"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QHash>
#include <QIcon>
#include <QImage>
#include <QImageReader>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QPainter>
#include <QBuffer>
#include <QQmlEngine>
#include <QQuickImageProvider>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QtConcurrent/qtconcurrentrun.h>
#include <QUuid>
#include <QUrl>
#include <algorithm>
#include <mutex>
Q_LOGGING_CATEGORY(notifLog, "ZShell.Services.notifs", QtInfoMsg)
namespace {
constexpr int MAX_ROWS = 500;
constexpr int MAX_PER_GROUP = 50;
constexpr qint64 MAX_AGE_MS = 30LL * 24 * 60 * 60 * 1000;
constexpr int TICK_MS = 250;
constexpr int MAX_IMAGE_DIM = 512;
// Memoized icon theme file lookups (hits and misses) per theme configuration.
constexpr int MAX_ICON_PATHS = 256;
constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000;
constexpr qint64 TIME_STR_REFRESH_MS = 2000;
// Coalesces content updates (live replaces_id edits, popup state) into one
// transaction instead of one UPDATE per changed field.
constexpr int FLUSH_MS = 300;
// Quickshell serves inline notification pictures through image://qsimage/
// (and qspixmap) handles that belong to the live notification, not to the
// shell: once that notification is replaced or dismissed, the handle is gone
// and the url fails to resolve. Such a source is only ever useful as the raw
// url long enough to read the bytes, so the UI must never see it.
bool transientImageSource(const QString& source) {
if (source.isEmpty()) {
return false;
}
const QUrl url(source);
return url.scheme() == QLatin1String("image") &&
(url.host() == QLatin1String("qsimage") ||
url.host() == QLatin1String("qspixmap"));
}
// The url the UI should use: the cached blob when there is one, and the raw
// source only while that source keeps resolving.
QString displayImage(const QString& source, const QString& digest) {
if (!digest.isEmpty()) {
return QStringLiteral("image://notifimg/") + digest;
}
return transientImageSource(source) ? QString() : source;
}
// Icon theme state, captured on the GUI thread. `QIcon::themeName()`,
// `fallbackThemeName()` and `themeSearchPaths()` touch GUI-thread only state,
// but the strings they return are safe to hand to a worker.
struct IconTheme {
QStringList searchPaths;
QStringList themes;
// Changes when the theme or the search paths change, so the path cache
// below can be dropped after a theme switch.
QString signature;
};
IconTheme iconTheme() {
IconTheme theme;
theme.searchPaths = QIcon::themeSearchPaths();
for (const auto& name :
{QIcon::themeName(),
QIcon::fallbackThemeName(),
QStringLiteral("hicolor"),
QStringLiteral("Adwaita")}) {
if (!name.isEmpty() && !theme.themes.contains(name)) {
theme.themes.append(name);
}
}
theme.signature = theme.searchPaths.join(QLatin1Char('\n')) +
QLatin1Char('\n') + theme.themes.join(QLatin1Char('\n'));
return theme;
}
// Resolve an icon name to a file the theme actually ships, so notification
// images that only name an icon can still be cached. KF6's KIconLoader is not
// thread-safe (it crashed the shell when the icon provider was called from a
// worker) and QIcon painting is GUI-thread only, so this only builds candidate
// paths and stats them - which makes it worker-thread safe when the theme
// strings come from iconTheme(). Hits and misses are both memoized: a sender
// repeats the same icon name, and a name the theme lacks costs thousands of
// stat calls to discover.
QString iconFileForName(const QString& name, const IconTheme& theme) {
if (name.isEmpty()) {
return {};
}
// Stat results only change when a theme is installed or removed, which the
// signature covers; the cap just keeps a stream of odd names bounded.
static std::mutex cacheMutex;
static QHash<QString, QString> pathCache;
static QString cacheSignature;
std::lock_guard<std::mutex> lock(cacheMutex);
if (cacheSignature != theme.signature) {
cacheSignature = theme.signature;
pathCache.clear();
}
const auto cached = pathCache.constFind(name);
if (cached != pathCache.constEnd()) {
return cached.value();
}
const QString found = [&]() {
if (QFileInfo::exists(name)) {
return name;
}
const QFileInfo info(name);
const QStringList names =
info.suffix().isEmpty()
? QStringList{name}
: QStringList{name, info.completeBaseName()};
static const QStringList sizes = {"256x256", "192x192", "128x128",
"96x96", "64x64", "48x48",
"32x32", "24x24", "22x22",
"16x16", "scalable", "256",
"192", "128", "96",
"64", "48", "32",
"24", "22", "16"};
static const QStringList contexts = {
"apps", "actions", "devices", "places", "status"};
static const QStringList extensions = {"svg", "svgz", "png"};
for (const auto& searchPath : theme.searchPaths) {
for (const auto& themeName : theme.themes) {
const QDir themeDir(QDir(searchPath).filePath(themeName));
if (!themeDir.exists()) {
continue;
}
for (const auto& candidateName : names) {
for (const auto& size : sizes) {
for (const auto& context : contexts) {
for (const auto& extension : extensions) {
const QString suffix =
candidateName + "." + extension;
// hicolor: <size>/<context>/<ext>/<name>.<ext>
const QString canonical = themeDir.filePath(
size + "/" + context + "/" + extension +
"/" + suffix);
if (QFileInfo::exists(canonical)) {
return canonical;
}
// hicolor and Papirus both also ship
// <size>/<context>/<name>.<ext>
const QString sized = themeDir.filePath(
size + "/" + context + "/" + suffix);
if (QFileInfo::exists(sized)) {
return sized;
}
// themes ordering size under context
const QString nested = themeDir.filePath(
context + "/" + size + "/" + suffix);
if (QFileInfo::exists(nested)) {
return nested;
}
}
}
}
// theme without size directories: <context>/<name>.<ext>
for (const auto& context : contexts) {
for (const auto& extension : extensions) {
const QString plain = themeDir.filePath(
context + "/" + candidateName + "." +
extension);
if (QFileInfo::exists(plain)) {
return plain;
}
}
}
}
}
}
return QString();
}();
if (pathCache.size() >= MAX_ICON_PATHS) {
pathCache.clear();
}
pathCache.insert(name, found);
return found;
}
} // namespace
namespace ZShell::Services {
class NotifImageProvider : public QQuickImageProvider {
public:
explicit NotifImageProvider(const QString& dbPath)
: QQuickImageProvider(QQuickImageProvider::Image), m_dbPath(dbPath) {}
void setDbPath(const QString& dbPath) {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_dbPath == dbPath) {
return;
}
m_dbPath = dbPath;
m_generation.fetch_add(1, std::memory_order_relaxed);
}
QImage requestImage(
const QString& id, QSize* size, const QSize& requestedSize) override {
if (id.isEmpty() || !openThreadDb(currentDbPath())) {
return {};
}
QSqlQuery query(QSqlDatabase::database(m_threadUuid));
query.prepare("SELECT png FROM images WHERE digest = ?");
query.addBindValue(id);
if (!query.exec() || !query.next()) {
return {};
}
const QByteArray bytes = query.value(0).toByteArray();
if (bytes.isEmpty()) {
return {};
}
QImage image = QImage::fromData(bytes, "PNG");
if (image.isNull()) {
return {};
}
if (size) {
*size = image.size();
}
if (requestedSize.isValid() && !requestedSize.isEmpty() &&
(image.width() > requestedSize.width() ||
image.height() > requestedSize.height())) {
image = image.scaled(
requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
return image;
}
private:
QString currentDbPath() const {
std::lock_guard<std::mutex> lock(m_mutex);
return m_dbPath;
}
bool openThreadDb(const QString& dbPath) {
const quint64 generation = m_generation.load(std::memory_order_relaxed);
if (!m_threadUuid.isEmpty() && m_threadGeneration == generation) {
return true;
}
if (!m_threadUuid.isEmpty()) {
{
auto db = QSqlDatabase::database(m_threadUuid, false);
if (db.isValid()) {
db.close();
}
}
QSqlDatabase::removeDatabase(m_threadUuid);
m_threadUuid.clear();
}
if (dbPath.isEmpty()) {
return false;
}
const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces);
auto db = QSqlDatabase::addDatabase("QSQLITE", uuid);
db.setConnectOptions("QSQLITE_OPEN_READONLY");
db.setDatabaseName(dbPath);
if (!db.open()) {
return false;
}
m_threadUuid = uuid;
m_threadGeneration = generation;
return true;
}
mutable std::mutex m_mutex;
QString m_dbPath;
std::atomic<quint64> m_generation = 0;
thread_local static QString m_threadUuid;
thread_local static quint64 m_threadGeneration;
};
thread_local QString NotifImageProvider::m_threadUuid;
thread_local quint64 NotifImageProvider::m_threadGeneration;
NotificationActionRow::NotificationActionRow(
QObject* action,
const QString& identifier,
const QString& text,
QObject* parent)
: QObject(parent)
, m_action(action)
, m_identifier(identifier)
, m_text(text) {
if (action) {
QObject::connect(action, &QObject::destroyed, this, [this]() {
emit invokableChanged();
});
}
}
bool NotificationActionRow::invokable() const {
return m_action != nullptr;
}
QString NotificationActionRow::identifier() const {
return m_identifier;
}
QString NotificationActionRow::text() const {
return m_text;
}
void NotificationActionRow::invoke() {
if (auto* action = m_action.data()) {
QMetaObject::invokeMethod(action, "invoke");
}
}
NotificationRow::NotificationRow(QObject* parent) : QObject(parent) {}
QQmlListProperty<NotificationActionRow> NotificationRow::actions() {
return QQmlListProperty<NotificationActionRow>(this, &m_actions);
}
QString NotificationRow::appIcon() const {
return m_appIcon;
}
QString NotificationRow::appName() const {
return m_appName;
}
QString NotificationRow::body() const {
return m_body;
}
bool NotificationRow::closed() const {
return m_closed;
}
bool NotificationRow::hasActionIcons() const {
return m_hasActionIcons;
}
QString NotificationRow::image() const {
return m_image;
}
QString NotificationRow::imageSource() const {
return m_imageSource;
}
int NotificationRow::notificationId() const {
return m_notificationId;
}
int NotificationRow::progress() const {
return m_progress;
}
bool NotificationRow::popup() const {
return m_popup;
}
void NotificationRow::setPopup(bool popup) {
if (m_popup == popup) {
return;
}
m_popup = popup;
m_popupPaused = false;
m_deadline =
popup ? QDateTime::currentMSecsSinceEpoch() + m_store->popupTimeout()
: 0;
emit popupChanged();
if (m_store != nullptr) {
m_store->markDirty(this);
m_store->updatePopupGroup(this);
}
}
bool NotificationRow::resident() const {
return m_resident;
}
QString NotificationRow::summary() const {
return m_summary;
}
QDateTime NotificationRow::time() const {
return QDateTime::fromMSecsSinceEpoch(m_timeMs);
}
QString NotificationRow::timeStr() const {
return m_timeStr;
}
int NotificationRow::urgency() const {
return m_urgency;
}
void NotificationRow::close() {
if (m_closed) {
return;
}
m_closed = true;
emit closedChanged();
if (auto* notification = m_notification.data()) {
QMetaObject::invokeMethod(notification, "dismiss");
}
m_store->handleClosed(this);
}
void NotificationRow::lock(QObject* item) {
if (item == nullptr || m_locks.contains(item)) {
return;
}
m_locks.insert(item);
// Goes through unlock() so releasing the last lock on an already-closed
// row finishes the removal the close started. The destroyed signal
// carries the dying object, which is the lock holder.
QObject::connect(item, &QObject::destroyed, this, [this](QObject* obj) {
unlock(obj);
});
}
void NotificationRow::unlock(QObject* item) {
m_locks.remove(item);
if (!m_locks.isEmpty()) {
return;
}
if (m_deleteAfterUnlock) {
m_deleteAfterUnlock = false;
deleteLater();
return;
}
if (m_closed) {
m_store->removeRow(this);
}
}
void NotificationRow::pausePopup() {
m_popupPaused = true;
}
void NotificationRow::resumePopup() {
m_popupPaused = false;
}
// The v2 notification UI uses caelestia's naming; these are thin aliases of the
// existing behaviour so both spellings work.
void NotificationRow::dismiss() {
close();
}
void NotificationRow::dismissPopup() {
if (m_popup) {
setPopup(false);
}
}
void NotificationRow::holdPopup() {
pausePopup();
}
void NotificationRow::releasePopup() {
resumePopup();
}
void NotificationRow::ref(QObject* item) {
lock(item);
}
void NotificationRow::unref(QObject* item) {
unlock(item);
}
// True when path is this row's image, in either its raw or cached form, so the
// UI can tell "the same picture changed url" from "a different picture".
bool NotificationRow::isSameImage(const QString& path) const {
return path == m_image || path == m_imageSource;
}
bool NotificationRow::isSameAppIcon(const QString& path) const {
return path == m_appIcon;
}
bool NotificationRow::expired(qint64 now) const {
return m_popup && !m_popupPaused && !m_closed && m_deadline > 0 &&
now >= m_deadline;
}
void NotificationRow::refreshTimeStr() {
const qint64 diff = QDateTime::currentMSecsSinceEpoch() - m_timeMs;
const qint64 m = diff / 60000;
QString next;
if (m < 1) {
next = tr("now");
} else {
const qint64 h = m / 60;
const qint64 d = h / 24;
if (d > 0) {
next = QStringLiteral("%1d").arg(d);
} else if (h > 0) {
next = QStringLiteral("%1h").arg(h);
} else {
next = QStringLiteral("%1m").arg(m);
}
}
if (next != m_timeStr) {
m_timeStr = next;
emit timeStrChanged();
}
}
NotificationGroup::NotificationGroup(const QString& appName, QObject* parent)
: QObject(parent), m_appName(appName) {}
QQmlListProperty<NotificationRow> NotificationGroup::activeNotifs() {
return QQmlListProperty<NotificationRow>(this, &m_active);
}
QString NotificationGroup::appIcon() const {
return m_iconSource ? m_iconSource->m_appIcon : QString();
}
QString NotificationGroup::appName() const {
return m_appName;
}
int NotificationGroup::count() const {
return m_count;
}
QString NotificationGroup::firstSummary() const {
return m_firstSummary;
}
QString NotificationGroup::image() const {
return m_imageSource ? m_imageSource->m_image : QString();
}
QQmlListProperty<NotificationRow> NotificationGroup::notifs() {
return QQmlListProperty<NotificationRow>(this, &m_notifs);
}
QString NotificationGroup::timeStr() const {
return m_timeStr;
}
int NotificationGroup::urgency() const {
return m_urgency;
}
void NotificationGroup::addRow(NotificationRow* row) {
m_notifs.prepend(row);
QObject::connect(
row, &NotificationRow::imageChanged, this, &NotificationGroup::sync);
QObject::connect(
row, &NotificationRow::appIconChanged, this, &NotificationGroup::sync);
QObject::connect(
row, &NotificationRow::urgencyChanged, this, &NotificationGroup::sync);
emit notifsChanged();
refreshActive();
sync();
}
void NotificationGroup::removeRow(NotificationRow* row) {
const bool had = m_notifs.removeOne(row);
if (!had) {
return;
}
QObject::disconnect(row, nullptr, this, nullptr);
emit notifsChanged();
// Must not drop the row from m_active here: refreshActive() only emits
// activeNotifsChanged when the list it rebuilds differs from the stored
// one, and a silently pre-edited m_active is equal to the rebuild. The
// popup stack reads activeNotifs, so skipping the emit left a closed row
// occupying its slot - an invisible gap other entries did not move into.
refreshActive();
sync();
}
void NotificationGroup::refreshActive() {
QList<NotificationRow*> active;
active.reserve(m_notifs.size());
for (auto* row : std::as_const(m_notifs)) {
if (!row->m_closed) {
active.append(row);
}
}
if (active != m_active) {
m_active = active;
emit activeNotifsChanged();
}
if (m_count != static_cast<int>(m_active.size())) {
m_count = static_cast<int>(m_active.size());
emit countChanged();
}
NotificationRow* first = m_active.isEmpty() ? nullptr : m_active.first();
const QString timeStr = first != nullptr ? first->m_timeStr : QString();
if (timeStr != m_timeStr) {
m_timeStr = timeStr;
emit timeStrChanged();
}
const QString summary = first != nullptr ? first->m_summary : QString();
if (summary != m_firstSummary) {
m_firstSummary = summary;
emit firstSummaryChanged();
}
}
void NotificationGroup::sync() {
NotificationRow* imageSource = nullptr;
NotificationRow* iconSource = nullptr;
int urgency = 0;
for (auto* row : std::as_const(m_active)) {
if (imageSource == nullptr && !row->m_image.isEmpty()) {
imageSource = row;
}
if (iconSource == nullptr && !row->m_appIcon.isEmpty()) {
iconSource = row;
}
urgency = (std::max)(urgency, row->m_urgency);
}
if (m_imageSource != imageSource) {
m_imageSource = imageSource;
emit imageChanged();
}
if (m_iconSource != iconSource) {
m_iconSource = iconSource;
emit appIconChanged();
}
if (urgency != m_urgency) {
m_urgency = urgency;
emit urgencyChanged();
}
}
// Delegates compare their own shown url against the group's current source row,
// so a row swapping raw source <-> cached blob does not re-trigger the fade.
bool NotificationGroup::isSameImage(const QString& path) const {
return m_imageSource != nullptr && m_imageSource->isSameImage(path);
}
bool NotificationGroup::isSameAppIcon(const QString& path) const {
return m_iconSource != nullptr && m_iconSource->isSameAppIcon(path);
}
void NotificationGroup::lock(QObject* item) {
if (item != nullptr && !m_locks.contains(item)) {
m_locks.insert(item);
// No Qt::UniqueConnection here: it requires a member-function pointer,
// and the contains() check above already keeps this one-shot per item.
QObject::connect(
item, &QObject::destroyed, this, [this, item](QObject*) {
unlock(item);
});
}
}
void NotificationGroup::unlock(QObject* item) {
m_locks.remove(item);
if (m_releasing && m_locks.isEmpty()) {
deleteLater();
}
}
// Called by the store once the group has no rows left. A delegate may still be
// animating it out, so the delete waits for its last unlock.
void NotificationGroup::release() {
m_releasing = true;
if (m_locks.isEmpty()) {
deleteLater();
}
}
NotificationStore::NotificationStore(QObject* parent)
: QObject(parent)
, m_uuid(QUuid::createUuid().toString(QUuid::WithoutBraces)) {
m_tick = new QTimer(this);
m_tick->setInterval(TICK_MS);
QObject::connect(m_tick, &QTimer::timeout, this, &NotificationStore::tick);
m_flush = new QTimer(this);
m_flush->setSingleShot(true);
m_flush->setInterval(FLUSH_MS);
QObject::connect(m_flush, &QTimer::timeout, this, &NotificationStore::flush);
// Config-backed settings, same pattern as TickingService: read the
// config node directly and update live on its change signals. QML may
// still write these properties; the next config change re-applies.
auto* notifs = config::ConfigSingleton::instance()->notifs();
applyNotifConfig(notifs);
QObject::connect(
notifs,
&config::Notifs::defaultExpireTimeoutChanged,
this,
[this, notifs] { applyNotifConfig(notifs); });
QObject::connect(
notifs, &config::Notifs::expireChanged, this, [this, notifs] {
applyNotifConfig(notifs);
});
}
NotificationStore::~NotificationStore() {
flush();
closeDb();
}
QString NotificationStore::path() const {
return m_path;
}
void NotificationStore::setPath(const QString& path) {
if (m_path == path || path.isEmpty()) {
return;
}
m_path = path;
emit pathChanged();
if (!openDb()) {
return;
}
migrateLegacyJson();
prune();
loadRows();
qCInfo(notifLog) << "NotificationStore: ready with" << m_rows.size()
<< "stored rows from" << m_path;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
m_nextPrune = now + PRUNE_INTERVAL_MS;
m_tick->start();
}
int NotificationStore::popupTimeout() const {
return m_popupTimeout;
}
void NotificationStore::setPopupTimeout(int timeout) {
if (m_popupTimeout == timeout || timeout <= 0) {
return;
}
m_popupTimeout = timeout;
emit popupTimeoutChanged();
}
bool NotificationStore::autoExpire() const {
return m_autoExpire;
}
void NotificationStore::setAutoExpire(bool autoExpire) {
if (m_autoExpire == autoExpire) {
return;
}
m_autoExpire = autoExpire;
emit autoExpireChanged();
}
void NotificationStore::applyNotifConfig(const config::Notifs* notifs) {
setPopupTimeout(notifs->defaultExpireTimeout());
setAutoExpire(notifs->expire());
}
QQmlListProperty<NotificationRow> NotificationStore::rows() {
return QQmlListProperty<NotificationRow>(this, &m_rows);
}
QQmlListProperty<NotificationGroup> NotificationStore::groups() {
return QQmlListProperty<NotificationGroup>(this, &m_groups);
}
QQmlListProperty<NotificationGroup> NotificationStore::popupGroups() {
return QQmlListProperty<NotificationGroup>(this, &m_popupGroups);
}
NotificationRow* NotificationStore::insert(
const QJSValue& notification, bool popup) {
const auto wrapped = notification.toVariant().value<QObject*>();
if (wrapped == nullptr) {
qWarning(notifLog) << "NotificationStore: expected a Notification";
return nullptr;
}
const int notificationId = wrapped->property("id").toInt();
if (wrapped->property("lastGeneration").toBool()) {
if (auto* row = rowForNotificationId(notificationId)) {
attachNotification(row, wrapped, notification);
return row;
}
}
auto* row = new NotificationRow(this);
row->m_store = this;
row->m_timeMs = QDateTime::currentMSecsSinceEpoch();
row->m_notificationId = notificationId;
copyFields(row, wrapped);
const auto hints = notification.property("hints");
if (hints.isObject()) {
row->m_progress = hints.property("value").toVariant().toInt();
}
attachNotification(row, wrapped, notification);
row->m_popup = popup;
if (popup) {
row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
}
insertDbRow(row);
startImageCache(row);
m_rows.prepend(row);
addGroup(row);
if (popup) {
addPopupGroup(row);
}
row->refreshTimeStr();
scheduleRowsChanged();
return row;
}
void NotificationStore::scheduleRowsChanged() {
if (m_rowsSignalPending) {
return;
}
m_rowsSignalPending = true;
QTimer::singleShot(0, this, [this] {
if (!m_rowsSignalPending) {
return; // an immediate emit (e.g. clear()) already covered it
}
m_rowsSignalPending = false;
emit rowsChanged();
});
}
void NotificationStore::clear() {
m_flush->stop();
m_dirty.clear();
const auto db = QSqlDatabase::database(m_uuid, false);
if (db.isOpen()) {
QSqlQuery query(db);
query.prepare("DELETE FROM notifications");
if (!query.exec()) {
qWarning(notifLog)
<< "NotificationStore: could not clear notifications"
<< query.lastError();
}
}
// The bulk delete above already removed every DB row; the per-row
// cleanup would just re-delete no-ops, so batch it instead.
m_clearing = true;
const auto rows = m_rows;
for (auto* row : rows) {
row->close();
}
m_clearing = false;
orphanCleanup();
m_rowsSignalPending = false; // supersede any pending coalesced emit
emit rowsChanged();
}
void NotificationStore::copyFields(NotificationRow* row, QObject* notification) {
row->m_summary = notification->property("summary").toString();
row->m_body = notification->property("body").toString();
row->m_appName = notification->property("appName").toString();
row->m_appIcon = notification->property("appIcon").toString();
row->m_imageSource = notification->property("image").toString();
row->m_image = displayImage(row->m_imageSource, row->m_digest);
row->m_resident = notification->property("resident").toBool();
row->m_hasActionIcons = notification->property("hasActionIcons").toBool();
row->m_urgency = notification->property("urgency").toInt();
}
void NotificationStore::attachNotification(
NotificationRow* row, QObject* notification, const QJSValue& js) {
row->m_notification = notification;
row->m_jsNotification = js;
m_liveRows.insert(notification, row);
if (!row->m_liveConnected) {
row->m_liveConnected = true;
const QPointer<NotificationRow> rowGuard(row);
QObject::connect(
notification,
&QObject::destroyed,
this,
[this, rowGuard, notification]() {
m_liveRows.remove(notification);
if (rowGuard == nullptr || rowGuard->m_closed) {
return;
}
rowGuard->m_closed = true;
emit rowGuard->closedChanged();
rowGuard->m_store->handleClosed(rowGuard);
});
// Quickshell applies replaces_id updates to the object we already
// hold; mirror those changes onto the row (in UI and in the DB).
// Connected straight to the slot (not through an intermediate
// signal) so sender() in handleLiveUpdate is the Notification.
static const QMetaMethod slot = [] {
const QMetaObject& storeMeta = NotificationStore::staticMetaObject;
return storeMeta.method(
storeMeta.indexOfSlot("handleLiveUpdate()"));
}();
static const QList<QByteArray> mirrored = {
"summaryChanged",
"bodyChanged",
"appNameChanged",
"appIconChanged",
"imageChanged",
"urgencyChanged",
"residentChanged",
"hasActionIconsChanged",
"actionsChanged",
"hintsChanged",
};
const auto* meta = notification->metaObject();
for (int i = 0; i < meta->methodCount(); i++) {
const auto method = meta->method(i);
if (method.methodType() != QMetaMethod::Signal) {
continue;
}
if (mirrored.contains(method.name())) {
QObject::connect(notification, method, this, slot);
}
}
}
qDeleteAll(row->m_actions);
row->m_actions.clear();
row->m_actions = parseActions(js.property("actions"), row);
emit row->actionsChanged();
}
void NotificationStore::handleLiveUpdate() {
const auto it = m_liveRows.constFind(sender());
if (it == m_liveRows.constEnd()) {
qWarning(notifLog) << "NotificationStore: live update for untracked "
"notification object";
return;
}
NotificationRow* row = it.value();
const QString summary = it.key()->property("summary").toString();
const QString body = it.key()->property("body").toString();
const QString appName = it.key()->property("appName").toString();
const QString appIcon = it.key()->property("appIcon").toString();
const QString imageSource = it.key()->property("image").toString();
const bool resident = it.key()->property("resident").toBool();
const bool hasActionIcons = it.key()->property("hasActionIcons").toBool();
const int urgency = it.key()->property("urgency").toInt();
const bool imageChanged = imageSource != row->m_imageSource;
if (summary != row->m_summary) {
row->m_summary = summary;
emit row->summaryChanged();
}
if (body != row->m_body) {
row->m_body = body;
emit row->bodyChanged();
}
if (appName != row->m_appName) {
row->m_appName = appName;
emit row->appNameChanged();
}
if (appIcon != row->m_appIcon) {
row->m_appIcon = appIcon;
emit row->appIconChanged();
}
if (urgency != row->m_urgency) {
row->m_urgency = urgency;
emit row->urgencyChanged();
}
if (resident != row->m_resident) {
row->m_resident = resident;
emit row->residentChanged();
}
if (hasActionIcons != row->m_hasActionIcons) {
row->m_hasActionIcons = hasActionIcons;
emit row->hasActionIconsChanged();
}
const auto hints = row->m_jsNotification.property("hints");
if (hints.isObject()) {
const int progress = hints.property("value").toVariant().toInt();
if (progress != row->m_progress) {
row->m_progress = progress;
emit row->progressChanged();
}
}
qDeleteAll(row->m_actions);
row->m_actions.clear();
row->m_actions =
parseActions(row->m_jsNotification.property("actions"), row);
emit row->actionsChanged();
if (imageChanged) {
row->m_imageSource = imageSource;
row->m_digest.clear();
row->m_image = displayImage(imageSource, row->m_digest);
emit row->imageSourceChanged();
emit row->imageChanged();
startImageCache(row);
}
// New content deserves a full look: restart the popup countdown.
if (row->m_popup) {
row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
}
// The row may have moved to another app name (replaces_id can change it).
if (row->m_group != nullptr && row->m_group->m_appName != row->m_appName) {
removeGroupRow(row);
addGroup(row);
if (row->m_popupGroup != nullptr) {
removePopupGroupRow(row);
addPopupGroup(row);
}
}
markDirty(row);
}
void NotificationStore::updateDbRow(NotificationRow* row) {
if (row->m_rowId < 0) {
return;
}
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
return;
}
QJsonArray actions;
for (auto* action : std::as_const(row->m_actions)) {
actions.append(
QJsonObject{
{"identifier", action->identifier()},
{"text", action->text()}});
}
QSqlQuery query(db);
query.prepare(
"UPDATE notifications SET"
" app_name = ?, app_icon = ?, summary = ?, body = ?,"
" urgency = ?, resident = ?, has_action_icons = ?, popup = ?,"
" actions = ?, image_source = ?, image_digest = ?, progress = ?"
" WHERE id = ?");
query.addBindValue(row->m_appName);
query.addBindValue(row->m_appIcon);
query.addBindValue(row->m_summary);
query.addBindValue(row->m_body);
query.addBindValue(row->m_urgency);
query.addBindValue(row->m_resident ? 1 : 0);
query.addBindValue(row->m_hasActionIcons ? 1 : 0);
query.addBindValue(row->m_popup ? 1 : 0);
query.addBindValue(
QString::fromUtf8(
QJsonDocument(actions).toJson(QJsonDocument::Compact)));
query.addBindValue(row->m_imageSource);
query.addBindValue(row->m_digest);
query.addBindValue(row->m_progress);
query.addBindValue(row->m_rowId);
if (!query.exec()) {
qWarning(notifLog) << "NotificationStore: could not update row"
<< query.lastError();
}
}
QList<NotificationActionRow*> NotificationStore::parseActions(
const QJSValue& actions, NotificationRow* row) {
QList<NotificationActionRow*> result;
if (!actions.isObject()) {
return result;
}
const int length = actions.property("length").toVariant().toInt();
for (int i = 0; i < length; i++) {
auto* object = actions.property(static_cast<quint32>(i))
.toVariant()
.value<QObject*>();
if (object == nullptr) {
continue;
}
result.append(new NotificationActionRow(
object,
object->property("identifier").toString(),
object->property("text").toString(),
row));
}
return result;
}
NotificationRow* NotificationStore::rowForNotificationId(int id) const {
for (auto* row : m_rows) {
if (row->m_notificationId == id && !row->m_closed) {
return row;
}
}
return nullptr;
}
void NotificationStore::handleClosed(NotificationRow* row) {
// During clear() the bulk DELETE already removed every row.
if (m_clearing) {
removeRow(row);
return;
}
const auto db = QSqlDatabase::database(m_uuid, false);
if (db.isOpen()) {
QSqlQuery query(db);
query.prepare("DELETE FROM notifications WHERE id = ?");
query.addBindValue(row->m_rowId);
query.exec();
}
m_dirty.remove(row);
removePopupGroupRow(row);
if (row->m_group != nullptr) {
row->m_group->refreshActive();
row->m_group->sync();
}
// A closed row leaves the store immediately, so its group and the sidebar
// order update at once; a delegate that still holds it only delays the
// delete, never the removal.
removeRow(row);
}
void NotificationStore::removeRow(NotificationRow* row) {
if (!m_rows.removeOne(row)) {
return;
}
removePopupGroupRow(row);
removeGroupRow(row);
m_dirty.remove(row);
// The row is gone; drop any live-notification mapping to it so
// handleLiveUpdate can never resolve a freed pointer.
erase_if(m_liveRows, [row](std::pair<const QObject*, NotificationRow*> e) {
return e.second == row;
});
// A delegate animating this row out still holds it; unlock() deletes once
// the last lock is gone.
if (row->m_locks.isEmpty()) {
row->deleteLater();
} else {
row->m_deleteAfterUnlock = true;
}
if (!m_clearing) {
orphanCleanup();
scheduleRowsChanged();
}
}
void NotificationStore::markDirty(NotificationRow* row) {
if (row->m_rowId < 0) {
return;
}
m_dirty.insert(row);
m_flush->start();
}
void NotificationStore::flush() {
m_flush->stop();
if (m_dirty.isEmpty()) {
return;
}
auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
m_dirty.clear();
return;
}
if (!db.transaction()) {
qWarning(notifLog) << "NotificationStore: flush transaction failed"
<< db.lastError();
}
for (auto* row : std::as_const(m_dirty)) {
updateDbRow(row);
}
m_dirty.clear();
if (!db.commit()) {
qWarning(notifLog) << "NotificationStore: flush commit failed"
<< db.lastError();
}
}
void NotificationStore::addGroup(NotificationRow* row) {
auto* group = m_groupsByName.value(row->m_appName, nullptr);
if (group == nullptr) {
group = new NotificationGroup(row->m_appName, this);
m_groupsByName.insert(row->m_appName, group);
m_groups.prepend(group);
emit groupsChanged();
} else {
// Most-recently-active group first, matching the sidebar order.
const qsizetype index = m_groups.indexOf(group);
if (index > 0) {
m_groups.move(index, 0);
emit groupsChanged();
}
}
row->m_group = group;
group->addRow(row);
}
void NotificationStore::removeGroupRow(NotificationRow* row) {
auto* group = row->m_group;
if (group == nullptr) {
return;
}
row->m_group = nullptr;
group->removeRow(row);
if (!group->m_notifs.isEmpty()) {
return;
}
m_groups.removeOne(group);
m_groupsByName.remove(group->m_appName);
group->release();
emit groupsChanged();
}
// Popup groups mirror m_groups over the rows currently showing as popups, so
// the popup stack can render one card per app instead of one per notification.
void NotificationStore::addPopupGroup(NotificationRow* row) {
if (row->m_popupGroup != nullptr) {
return;
}
auto* group = m_popupGroupsByName.value(row->m_appName, nullptr);
if (group == nullptr) {
group = new NotificationGroup(row->m_appName, this);
m_popupGroupsByName.insert(row->m_appName, group);
m_popupGroups.prepend(group);
emit popupGroupsChanged();
} else {
const qsizetype index = m_popupGroups.indexOf(group);
if (index > 0) {
m_popupGroups.move(index, 0);
emit popupGroupsChanged();
}
}
row->m_popupGroup = group;
group->addRow(row);
}
void NotificationStore::removePopupGroupRow(NotificationRow* row) {
auto* group = row->m_popupGroup;
if (group == nullptr) {
return;
}
row->m_popupGroup = nullptr;
group->removeRow(row);
if (!group->m_notifs.isEmpty()) {
return;
}
m_popupGroups.removeOne(group);
m_popupGroupsByName.remove(group->m_appName);
group->release();
emit popupGroupsChanged();
}
void NotificationStore::updatePopupGroup(NotificationRow* row) {
if (row->m_popup && !row->m_closed) {
addPopupGroup(row);
return;
}
removePopupGroupRow(row);
}
void NotificationStore::insertDbRow(NotificationRow* row) {
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
return;
}
QJsonArray actions;
for (auto* action : std::as_const(row->m_actions)) {
actions.append(
QJsonObject{
{"identifier", action->identifier()},
{"text", action->text()}});
}
QSqlQuery query(db);
query.prepare(
"INSERT INTO notifications"
" (notification_id, app_name, app_icon, summary, body, time,"
" urgency, resident, has_action_icons, popup, actions,"
" image_source, image_digest, progress)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
query.addBindValue(row->m_notificationId);
query.addBindValue(row->m_appName);
query.addBindValue(row->m_appIcon);
query.addBindValue(row->m_summary);
query.addBindValue(row->m_body);
query.addBindValue(row->m_timeMs);
query.addBindValue(row->m_urgency);
query.addBindValue(row->m_resident ? 1 : 0);
query.addBindValue(row->m_hasActionIcons ? 1 : 0);
query.addBindValue(row->m_popup ? 1 : 0);
query.addBindValue(
QString::fromUtf8(
QJsonDocument(actions).toJson(QJsonDocument::Compact)));
query.addBindValue(row->m_imageSource);
query.addBindValue(row->m_digest);
query.addBindValue(row->m_progress);
if (!query.exec()) {
qWarning(notifLog) << "NotificationStore: could not insert row"
<< query.lastError();
return;
}
row->m_rowId = query.lastInsertId().toLongLong();
}
bool NotificationStore::openDb() {
closeDb();
QDir().mkpath(QFileInfo(m_path).absolutePath());
auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid);
db.setDatabaseName(m_path);
if (!db.open()) {
qWarning(notifLog) << "NotificationStore: could not open database"
<< m_path << db.lastError();
return false;
}
QSqlQuery query(db);
const QStringList statements = {
// WAL + NORMAL: commits don't fsync per insert (the fsync is what
// made every notification insert 5-8ms). Safe with WAL: an
// application crash still recovers the WAL; only an OS/power
// failure can lose the most recent commits.
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"CREATE TABLE IF NOT EXISTS notifications("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
" notification_id INTEGER, app_name TEXT, app_icon TEXT,"
" summary TEXT, body TEXT, time INTEGER, urgency INTEGER,"
" resident INTEGER, has_action_icons INTEGER, popup INTEGER,"
" actions TEXT, image_source TEXT, image_digest TEXT,"
" progress INTEGER)",
"CREATE TABLE IF NOT EXISTS images("
"digest TEXT PRIMARY KEY, png BLOB)",
"CREATE INDEX IF NOT EXISTS notifications_time"
" ON notifications(time DESC)",
};
for (const auto& statement : statements) {
if (!query.exec(statement)) {
qWarning(notifLog)
<< "NotificationStore: could not prepare database schema"
<< statement << query.lastError();
closeDb();
return false;
}
}
// Databases created before progress was stored lack the column; ALTER adds
// it instead of dropping history (caelestia drops and recreates).
if (!db.record("notifications").contains("progress")) {
QSqlQuery alter(db);
if (!alter.exec(
"ALTER TABLE notifications ADD COLUMN progress INTEGER")) {
qWarning(notifLog)
<< "NotificationStore: could not add progress column"
<< alter.lastError();
closeDb();
return false;
}
}
if (m_provider != nullptr) {
m_provider->setDbPath(m_path);
return true;
}
registerProvider();
return true;
}
void NotificationStore::orphanCleanup() {
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
return;
}
QSqlQuery query(db);
if (!query.exec(
"DELETE FROM images WHERE digest NOT IN"
" (SELECT DISTINCT image_digest FROM notifications"
" WHERE image_digest != '')")) {
qWarning(notifLog)
<< "NotificationStore: could not clean up orphaned images"
<< query.lastError();
}
}
void NotificationStore::registerProvider() {
if (m_provider != nullptr) {
return;
}
auto* engine = qmlEngine(this);
if (engine == nullptr) {
return;
}
m_provider = new NotifImageProvider(m_path);
engine->addImageProvider(QStringLiteral("notifimg"), m_provider);
}
void NotificationStore::closeDb() {
if (!QSqlDatabase::contains(m_uuid)) {
return;
}
{
auto db = QSqlDatabase::database(m_uuid, false);
if (db.isValid()) {
db.close();
}
}
QSqlDatabase::removeDatabase(m_uuid);
}
void NotificationStore::loadRows() {
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
return;
}
QSqlQuery query(db);
query.exec(
"SELECT id, notification_id, app_name, app_icon, summary, body, time,"
" urgency, resident, has_action_icons, popup, actions,"
" image_source, image_digest, progress"
" FROM notifications ORDER BY time DESC");
while (query.next()) {
auto* row = new NotificationRow(this);
row->m_store = this;
row->m_rowId = query.value(0).toLongLong();
row->m_notificationId = query.value(1).toInt();
row->m_appName = query.value(2).toString();
row->m_appIcon = query.value(3).toString();
row->m_summary = query.value(4).toString();
row->m_body = query.value(5).toString();
row->m_timeMs = query.value(6).toLongLong();
row->m_urgency = query.value(7).toInt();
row->m_resident = query.value(8).toInt() != 0;
row->m_hasActionIcons = query.value(9).toInt() != 0;
// Popup state is deliberately not restored: showing stored
// notifications as toasts again after a shell restart would
// replay an old burst, and with autoExpire off the flag never
// clears itself. The rows stay in the sidebar instead.
row->m_popup = false;
row->m_imageSource = query.value(12).toString();
row->m_digest = query.value(13).toString();
row->m_progress = query.value(14).toInt();
const auto actions =
QJsonDocument::fromJson(query.value(11).toString().toUtf8());
if (actions.isArray()) {
for (const auto& action : actions.array()) {
const auto object = action.toObject();
row->m_actions.append(new NotificationActionRow(
nullptr,
object.value("identifier").toString(),
object.value("text").toString(),
row));
}
}
row->m_image = displayImage(row->m_imageSource, row->m_digest);
row->refreshTimeStr();
if (row->m_digest.isEmpty()) {
startImageCache(row);
}
m_rows.append(row);
addGroup(row);
}
emit rowsChanged();
}
void NotificationStore::migrateLegacyJson() {
const QString legacy =
QDir(QFileInfo(m_path).absolutePath()).filePath("notifs.json");
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen() || !QFile::exists(legacy)) {
return;
}
QSqlQuery count(db);
count.exec("SELECT COUNT(*) FROM notifications");
if (count.next() && count.value(0).toInt() != 0) {
return;
}
QFile file(legacy);
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const auto doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isArray()) {
return;
}
for (const auto& value : doc.array()) {
const auto object = value.toObject();
auto* row = new NotificationRow(this);
row->m_store = this;
row->m_appName = object.value("appName").toString();
row->m_appIcon = object.value("appIcon").toString();
row->m_summary = object.value("summary").toString();
row->m_body = object.value("body").toString();
row->m_imageSource = object.value("image").toString();
row->m_notificationId = object.value("id").toInt();
row->m_urgency = object.value("urgency").toInt(1);
row->m_resident = object.value("resident").toBool();
row->m_hasActionIcons = object.value("hasActionIcons").toBool();
// Same reasoning as loadRows(): never re-toast migrated rows.
row->m_popup = false;
const auto time = object.value("time");
if (time.isDouble()) {
row->m_timeMs = static_cast<qint64>(time.toDouble());
} else {
const auto parsed =
QDateTime::fromString(time.toString(), Qt::ISODate);
row->m_timeMs = parsed.isValid()
? parsed.toMSecsSinceEpoch()
: QDateTime::currentMSecsSinceEpoch();
}
for (const auto& action : object.value("actions").toArray()) {
const auto actionObject = action.toObject();
row->m_actions.append(new NotificationActionRow(
nullptr,
actionObject.value("identifier").toString(),
actionObject.value("text").toString(),
row));
}
row->m_image = displayImage(row->m_imageSource, row->m_digest);
row->refreshTimeStr();
insertDbRow(row);
startImageCache(row);
m_rows.append(row);
addGroup(row);
}
if (!m_rows.isEmpty()) {
emit rowsChanged();
}
file.close();
QFile::remove(legacy + ".migrated");
QFile::rename(legacy, legacy + ".migrated");
qCInfo(notifLog) << "NotificationStore: migrated legacy notifs.json";
}
void NotificationStore::prune() {
flush();
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
return;
}
QSqlQuery query(db);
query.prepare("DELETE FROM notifications WHERE time < ?");
query.addBindValue(QDateTime::currentMSecsSinceEpoch() - MAX_AGE_MS);
if (!query.exec()) {
qWarning(notifLog)
<< "NotificationStore: could not prune old notifications"
<< query.lastError();
}
// Total cap, preferring higher urgency so a chatty app cannot push out
// an important notification.
if (!query.exec(
"DELETE FROM notifications WHERE id NOT IN"
" (SELECT id FROM notifications ORDER BY urgency DESC, time DESC"
" LIMIT " +
QString::number(MAX_ROWS) + ")")) {
qWarning(notifLog)
<< "NotificationStore: could not trim notification rows"
<< query.lastError();
}
// Per-app cap: one app flooding must not crowd out the other groups.
QStringList appNames;
if (query.exec("SELECT DISTINCT app_name FROM notifications")) {
while (query.next()) {
appNames.append(query.value(0).toString());
}
}
for (const auto& appName : std::as_const(appNames)) {
QSqlQuery perGroup(db);
perGroup.prepare(
"DELETE FROM notifications WHERE app_name = ? AND id NOT IN"
" (SELECT id FROM notifications WHERE app_name = ?"
" ORDER BY urgency DESC, time DESC LIMIT " +
QString::number(MAX_PER_GROUP) + ")");
perGroup.addBindValue(appName);
perGroup.addBindValue(appName);
if (!perGroup.exec()) {
qWarning(notifLog) << "NotificationStore: could not trim group"
<< appName << perGroup.lastError();
}
}
orphanCleanup();
trimRows();
}
void NotificationStore::trimRows() {
if (m_rows.size() <= MAX_ROWS) {
return;
}
// Only closed, unlocked rows are droppable (open rows may still be
// acted on by the sender). Weakest first: lowest urgency, then oldest.
const auto db = QSqlDatabase::database(m_uuid, false);
while (m_rows.size() > MAX_ROWS) {
NotificationRow* victim = nullptr;
for (auto* row : std::as_const(m_rows)) {
if (!row->m_closed || !row->m_locks.isEmpty()) {
continue;
}
if (victim == nullptr) {
victim = row;
continue;
}
const bool weaker = row->m_urgency < victim->m_urgency ||
(row->m_urgency == victim->m_urgency &&
row->m_timeMs <= victim->m_timeMs);
if (weaker) {
victim = row;
}
}
if (victim == nullptr) {
return;
}
if (victim->m_rowId >= 0 && db.isOpen()) {
QSqlQuery query(db);
query.prepare("DELETE FROM notifications WHERE id = ?");
query.addBindValue(victim->m_rowId);
query.exec();
}
removeRow(victim);
}
}
void NotificationStore::startImageCache(NotificationRow* row) {
if (row->m_imageSource.isEmpty()) {
return;
}
registerProvider();
const QUrl url(row->m_imageSource);
// Resolve *what* to read on the main thread (engine provider lookups and
// QIcon's theme state belong here), but do no file scanning and no
// decoding here: providers are designed to serve requestImage from non-GUI
// threads, and SVG rasterization was blocking the shell 3-9ms per
// notification.
QString localPath;
QQuickImageProvider* provider = nullptr;
QString providerId;
bool usePixmap = false;
QImage inlineImage;
QString iconName;
IconTheme theme;
if (url.isLocalFile()) {
localPath = url.toLocalFile();
} else if (url.scheme() == QLatin1String("image")) {
const QString id = url.path().startsWith(QLatin1Char('/'))
? url.path().mid(1)
: url.path();
const QString host = url.host();
if (host != QLatin1String("qsimage") &&
host != QLatin1String("qspixmap")) {
// Provider urls such as image://icon/... are served by KF6's
// KIconLoader, which is not thread-safe (calling it from a
// worker crashed the shell). Only QIcon's theme state is read
// here, because the GUI thread owns it; the candidate scanning it
// feeds is pure filesystem work and runs in the worker below. A
// name the theme does not have means thousands of stat calls, and
// doing those here stalled inserts for milliseconds. If nothing
// matches, the row keeps rendering the url directly.
iconName = id;
theme = iconTheme();
} else {
// Transient inline notification images (image://qsimage/...).
// Provider objects are engine-owned and outlive this task;
// ids owned by a destroyed notification resolve to nothing.
auto* engine = qmlEngine(this);
if (engine == nullptr) {
return;
}
auto* base = const_cast<QQmlImageProviderBase*>(
engine->imageProvider(url.host()));
provider = dynamic_cast<QQuickImageProvider*>(base);
if (provider == nullptr) {
return;
}
providerId = id;
switch (provider->imageType()) {
case QQuickImageProvider::Image:
usePixmap = false;
break;
case QQuickImageProvider::Pixmap:
usePixmap = true;
break;
default:
return;
}
// Copy the pixels here instead of in the worker: the handle
// belongs to the sender's notification, and it can be gone by the
// time the task runs, which silently dropped the picture. This is
// a memory copy of an already decoded image, not a rasterization.
inlineImage =
usePixmap
? provider->requestPixmap(providerId, nullptr, QSize())
.toImage()
: provider->requestImage(providerId, nullptr, QSize());
if (inlineImage.isNull()) {
return;
}
}
}
const QPointer<NotificationRow> guard(row);
auto* watcher = new QFutureWatcher<std::pair<QString, QByteArray>>(this);
QObject::connect(
watcher,
&QFutureWatcher<std::pair<QString, QByteArray>>::finished,
this,
[this, watcher, guard]() {
const auto result = watcher->result();
if (!result.first.isEmpty()) {
storeImageBlob(guard, result.first, result.second);
}
watcher->deleteLater();
});
watcher->setFuture(
QtConcurrent::run([localPath,
iconName,
theme,
provider,
providerId,
usePixmap,
inlineImage]() {
QImage image = inlineImage;
QString path = localPath;
if (image.isNull() && path.isEmpty() && !iconName.isEmpty()) {
path = iconFileForName(iconName, theme);
}
if (image.isNull() && !path.isEmpty()) {
QImageReader reader(path);
reader.setAutoTransform(true);
image = reader.read();
} else if (image.isNull() && provider != nullptr) {
image =
usePixmap
? provider->requestPixmap(providerId, nullptr, QSize())
.toImage()
: provider->requestImage(providerId, nullptr, QSize());
}
if (image.isNull()) {
return std::pair<QString, QByteArray>();
}
QImage normalized = image;
if (normalized.width() > MAX_IMAGE_DIM ||
normalized.height() > MAX_IMAGE_DIM) {
normalized = normalized.scaled(
MAX_IMAGE_DIM,
MAX_IMAGE_DIM,
Qt::KeepAspectRatio,
Qt::SmoothTransformation);
}
normalized = normalized.convertToFormat(QImage::Format_RGBA8888);
// Cache the avatar content already cut to a circle, so the view
// does not have to mask it with a layer effect every frame.
const int side =
(std::min)(normalized.width(), normalized.height());
const QRect square(
(normalized.width() - side) / 2,
(normalized.height() - side) / 2,
side,
side);
QImage circle(side, side, QImage::Format_RGBA8888);
circle.fill(Qt::transparent);
QImage mask(side, side, QImage::Format_ARGB32);
mask.fill(Qt::transparent);
{
QPainter maskPainter(&mask);
maskPainter.setRenderHint(QPainter::Antialiasing);
maskPainter.setPen(Qt::NoPen);
maskPainter.setBrush(QColor(0, 0, 0, 255));
maskPainter.drawEllipse(QRect(0, 0, side, side));
}
{
QPainter painter(&circle);
painter.setCompositionMode(QPainter::CompositionMode_Source);
painter.drawImage(QRect(0, 0, side, side), normalized, square);
painter.setCompositionMode(
QPainter::CompositionMode_DestinationIn);
painter.drawImage(0, 0, mask);
}
normalized = circle;
const QByteArray bytes(
reinterpret_cast<const char*>(normalized.constBits()),
normalized.sizeInBytes());
const QString digest = QString::fromLatin1(
QCryptographicHash::hash(bytes, QCryptographicHash::Sha256)
.toHex());
QByteArray png;
QBuffer buffer(&png);
if (!buffer.open(QIODevice::WriteOnly) ||
!normalized.save(&buffer, "PNG")) {
return std::pair<QString, QByteArray>();
}
return std::make_pair(digest, png);
}));
}
void NotificationStore::storeImageBlob(
const QPointer<NotificationRow>& row,
const QString& digest,
const QByteArray& png) {
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
return;
}
QSqlQuery query(db);
query.prepare(
"INSERT INTO images (digest, png) VALUES (?, ?)"
" ON CONFLICT(digest) DO NOTHING");
query.addBindValue(digest);
query.addBindValue(png);
query.exec();
if (row != nullptr) {
if (row->m_rowId >= 0) {
QSqlQuery update(db);
update.prepare(
"UPDATE notifications SET image_digest = ?"
" WHERE id = ?");
update.addBindValue(digest);
update.addBindValue(row->m_rowId);
update.exec();
}
row->m_digest = digest;
row->m_image = QStringLiteral("image://notifimg/") + digest;
// The group reads its image through the source row and listens to
// imageChanged, so this emit is enough to keep the card in sync.
emit row->imageChanged();
}
}
void NotificationStore::tick() {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
for (auto* row : std::as_const(m_rows)) {
if (m_autoExpire && row->expired(now)) {
row->setPopup(false);
}
}
if (m_nextTimeStrRefresh <= now) {
m_nextTimeStrRefresh = now + TIME_STR_REFRESH_MS;
for (auto* row : std::as_const(m_rows)) {
row->refreshTimeStr();
}
for (auto* group : std::as_const(m_groups)) {
group->refreshActive();
}
}
if (m_nextPrune <= now) {
m_nextPrune = now + PRUNE_INTERVAL_MS;
prune();
trimRows();
}
}
} // namespace ZShell::Services