Files
z-bar-qt/Plugins/ZShell/Services/notificationstore.cpp
T

1375 lines
34 KiB
C++

#include "notificationstore.hpp"
#include "config/notifs.hpp"
#include "config/rootnodes.hpp"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QImage>
#include <QImageReader>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QBuffer>
#include <QQmlEngine>
#include <QQuickImageProvider>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QtConcurrent/qtconcurrentrun.h>
#include <QUuid>
#include <mutex>
Q_LOGGING_CATEGORY(notifLog, "ZShell.Services.notifs", QtInfoMsg)
namespace {
constexpr int MAX_ROWS = 500;
constexpr qint64 MAX_AGE_MS = 30LL * 24 * 60 * 60 * 1000;
constexpr int TICK_MS = 250;
constexpr int MAX_IMAGE_DIM = 512;
constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000;
constexpr qint64 TIME_STR_REFRESH_MS = 2000;
} // 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->persistPopup(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);
QObject::connect(item, &QObject::destroyed, this, [this, item]() {
m_locks.remove(item);
});
}
void NotificationRow::unlock(QObject* item) {
m_locks.remove(item);
if (m_closed && m_locks.isEmpty()) {
m_store->removeRow(this);
}
}
void NotificationRow::pausePopup() {
m_popupPaused = true;
}
void NotificationRow::resumePopup() {
m_popupPaused = false;
}
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();
}
}
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);
// 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() {
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);
}
NotificationRow* NotificationStore::insert(
const QJSValue& notification, bool popup) {
QElapsedTimer clock;
clock.start();
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);
const qint64 dbMs = clock.elapsed();
startImageCache(row);
const qint64 imageMs = clock.elapsed() - dbMs;
m_rows.prepend(row);
row->refreshTimeStr();
scheduleRowsChanged();
// rowsChanged is coalesced onto the next event loop turn, so the QML
// reaction (filter re-evaluation + delegate rebind) runs once for a burst
// of inserts instead of once per notification. This timing therefore
// covers the synchronous part: attach + db + image cache start.
if (const qint64 totalMs = clock.elapsed(); totalMs >= 3) {
qCDebug(notifLog) << "perf: insert total" << totalMs
<< "ms | attach+db" << dbMs << "ms | image" << imageMs << "ms";
}
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() {
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 = row->m_imageSource;
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 = imageSource;
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;
}
updateDbRow(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 = ?,"
" actions = ?, image_source = ?, image_digest = ?"
" 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(
QString::fromUtf8(QJsonDocument(actions).toJson(QJsonDocument::Compact)));
query.addBindValue(row->m_imageSource);
query.addBindValue(row->m_digest);
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) {
if (row->m_locks.isEmpty()) {
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();
}
if (row->m_locks.isEmpty()) {
removeRow(row);
}
}
void NotificationStore::removeRow(NotificationRow* row) {
if (!m_rows.removeOne(row)) {
return;
}
// 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;
});
row->deleteLater();
if (!m_clearing) {
orphanCleanup();
scheduleRowsChanged();
}
}
void NotificationStore::persistPopup(const NotificationRow* row) {
if (row->m_rowId < 0) {
return;
}
const auto db = QSqlDatabase::database(m_uuid, false);
if (!db.isOpen()) {
return;
}
QSqlQuery query(db);
query.prepare("UPDATE notifications SET popup = ? WHERE id = ?");
query.addBindValue(row->m_popup ? 1 : 0);
query.addBindValue(row->m_rowId);
if (!query.exec()) {
qWarning(notifLog)
<< "NotificationStore: could not persist popup state"
<< query.lastError();
}
}
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)"
" 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);
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)",
"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;
}
}
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"
" 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;
row->m_popup = query.value(10).toInt() != 0;
row->m_imageSource = query.value(12).toString();
row->m_digest = query.value(13).toString();
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));
}
}
if (row->m_popup) {
row->m_deadline =
QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
}
row->m_image = row->m_digest.isEmpty() ? row->m_imageSource
: QStringLiteral("image://notifimg/") +
row->m_digest;
row->refreshTimeStr();
if (row->m_digest.isEmpty()) {
startImageCache(row);
}
m_rows.append(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();
row->m_popup = object.value("popup").toBool();
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 = row->m_imageSource;
if (row->m_popup) {
row->m_deadline =
QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
}
row->refreshTimeStr();
insertDbRow(row);
startImageCache(row);
m_rows.append(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() {
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();
}
if (!query.exec(
"DELETE FROM notifications WHERE id NOT IN"
" (SELECT id FROM notifications ORDER BY time DESC"
" LIMIT " +
QString::number(MAX_ROWS) + ")")) {
qWarning(notifLog)
<< "NotificationStore: could not trim notification rows"
<< query.lastError();
}
orphanCleanup();
trimRows();
}
void NotificationStore::trimRows() {
if (m_rows.size() <= MAX_ROWS) {
return;
}
// m_rows is newest-first. Drop closed rows from the oldest end until
// the cap is met; open rows stay (few, and the sender may act on
// them). Their DB rows are removed in case the DB trim above kept a
// different set (closed rows already deleted their DB row on close,
// so the two views can differ).
const auto db = QSqlDatabase::database(m_uuid, false);
for (qsizetype i = m_rows.size() - 1;
i >= 0 && m_rows.size() > MAX_ROWS; i--) {
NotificationRow* row = m_rows.at(i);
if (!row->m_closed || !row->m_locks.isEmpty()) {
continue;
}
if (row->m_rowId >= 0 && db.isOpen()) {
QSqlQuery query(db);
query.prepare("DELETE FROM notifications WHERE id = ?");
query.addBindValue(row->m_rowId);
query.exec();
}
removeRow(row);
}
}
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
// belong here), but do 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;
if (url.isLocalFile()) {
localPath = url.toLocalFile();
} else if (url.scheme() == QLatin1String("image")) {
const QString host = url.host();
if (host != QLatin1String("qsimage") && host != QLatin1String("qspixmap")) {
// Persistent urls (e.g. image://icon/...) resolve at any
// shell start; caching them is pointless, and calling the
// icon provider from this thread races KF6's KIconLoader,
// which is not thread-safe (crashed the shell). The row
// keeps rendering the url directly.
return;
}
// 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 = url.path().startsWith(QLatin1Char('/'))
? url.path().mid(1)
: url.path();
switch (provider->imageType()) {
case QQuickImageProvider::Image:
usePixmap = false;
break;
case QQuickImageProvider::Pixmap:
usePixmap = true;
break;
default:
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, provider, providerId, usePixmap]() {
QImage image;
if (!localPath.isEmpty()) {
QImageReader reader(localPath);
reader.setAutoTransform(true);
image = reader.read();
} else if (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);
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;
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();
}
}
if (m_nextPrune <= now) {
m_nextPrune = now + PRUNE_INTERVAL_MS;
prune();
trimRows();
}
}
} // namespace ZShell::Services