#include "notificationstore.hpp" #include "config/notifs.hpp" #include "config/rootnodes.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 = 100; constexpr int MAX_IMAGE_DIM = 512; constexpr int MAX_ICON_PATHS = 256; constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000; constexpr qint64 TIME_STR_REFRESH_MS = 2000; constexpr qint64 MAX_EXPIRE_MS = 5LL * 60 * 1000; constexpr int FLUSH_MS = 300; 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")); } QString displayImage(const QString& source, const QString& digest) { if (!digest.isEmpty()) { return QStringLiteral("image://notifimg/") + digest; } return transientImageSource(source) ? QString() : source; } struct IconTheme { QStringList searchPaths; QStringList themes; 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; } QString iconFileForName(const QString& name, const IconTheme& theme) { if (name.isEmpty()) { return {}; } static std::mutex cacheMutex; static QHash pathCache; static QString cacheSignature; std::lock_guard 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; const QString canonical = themeDir.filePath( size + "/" + context + "/" + extension + "/" + suffix); if (QFileInfo::exists(canonical)) { return canonical; } const QString sized = themeDir.filePath( size + "/" + context + "/" + suffix); if (QFileInfo::exists(sized)) { return sized; } const QString nested = themeDir.filePath( context + "/" + size + "/" + suffix); if (QFileInfo::exists(nested)) { return nested; } } } } 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 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 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 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 NotificationRow::actions() { return QQmlListProperty(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; 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); 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() { if (m_popupGroup != nullptr) { m_popupGroup->holdExpiry(); } } void NotificationRow::resumePopup() { if (m_popupGroup != nullptr) { m_popupGroup->releaseExpiry(); } } 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); } 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; } 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 NotificationGroup::activeNotifs() { return QQmlListProperty(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; } bool NotificationGroup::expireActive() const { return m_expireActive; } bool NotificationGroup::expirePaused() const { return m_expirePaused; } qreal NotificationGroup::expireProgress() const { return m_expireProgress; } int NotificationGroup::expireRemaining() const { return m_expireRemaining; } int NotificationGroup::expireTimeout() const { return m_expireTimeout; } QString NotificationGroup::firstSummary() const { return m_firstSummary; } QString NotificationGroup::image() const { return m_imageSource ? m_imageSource->m_image : QString(); } QQmlListProperty NotificationGroup::notifs() { return QQmlListProperty(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(); refreshActive(); sync(); } void NotificationGroup::refreshActive() { QList 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(m_active.size())) { m_count = static_cast(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(); } } void NotificationGroup::resetExpiry(int timeout) { const qint64 now = QDateTime::currentMSecsSinceEpoch(); if (timeout != m_expireTimeout) { m_expireTimeout = timeout; emit expireTimeoutChanged(); } if (timeout <= 0) { m_expireDeadline = 0; m_expireHeld = 0; if (m_expirePaused) { m_expirePaused = false; emit expirePausedChanged(); } } else if (m_expirePaused) { m_expireHeld = timeout; } else { m_expireDeadline = now + timeout; } refreshExpiry(now); emit expireTimerRestarted(); } void NotificationGroup::holdExpiry() { if (m_expirePaused || m_expireDeadline <= 0) { return; } const qint64 now = QDateTime::currentMSecsSinceEpoch(); m_expireHeld = (std::max)(qint64(0), m_expireDeadline - now); m_expireDeadline = 0; m_expirePaused = true; emit expirePausedChanged(); refreshExpiry(now); } void NotificationGroup::releaseExpiry() { if (!m_expirePaused) { return; } const qint64 now = QDateTime::currentMSecsSinceEpoch(); m_expireDeadline = now + m_expireHeld; m_expireHeld = 0; m_expirePaused = false; emit expirePausedChanged(); refreshExpiry(now); } void NotificationGroup::clearExpiry() { m_expireDeadline = 0; m_expireHeld = 0; if (m_expirePaused) { m_expirePaused = false; emit expirePausedChanged(); } refreshExpiry(QDateTime::currentMSecsSinceEpoch()); } bool NotificationGroup::expired(qint64 now) const { return m_expireDeadline > 0 && !m_expirePaused && now >= m_expireDeadline; } void NotificationGroup::refreshExpiry(qint64 now) { qint64 remaining = 0; if (m_expirePaused) { remaining = m_expireHeld; } else if (m_expireDeadline > 0) { remaining = (std::max)(qint64(0), m_expireDeadline - now); } if (remaining != m_expireRemaining) { m_expireRemaining = static_cast(remaining); emit expireRemainingChanged(); } const qreal progress = m_expireTimeout > 0 ? (std::min)(1.0, static_cast(remaining) / m_expireTimeout) : 0; if (!qFuzzyCompare(progress, m_expireProgress)) { m_expireProgress = progress; emit expireProgressChanged(); } setExpireActive(m_expireDeadline > 0 || m_expirePaused); } void NotificationGroup::setExpireActive(bool active) { if (active != m_expireActive) { m_expireActive = active; emit expireActiveChanged(); } } 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); 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(); } } 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); 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(); if (m_autoExpire) { for (auto* group : std::as_const(m_popupGroups)) { group->resetExpiry(groupExpireTimeoutMs(group)); } } } bool NotificationStore::autoExpire() const { return m_autoExpire; } void NotificationStore::setAutoExpire(bool autoExpire) { if (m_autoExpire == autoExpire) { return; } m_autoExpire = autoExpire; emit autoExpireChanged(); for (auto* group : std::as_const(m_popupGroups)) { if (autoExpire) { group->resetExpiry(groupExpireTimeoutMs(group)); } else { group->clearExpiry(); } } } void NotificationStore::applyNotifConfig(const config::Notifs* notifs) { setPopupTimeout(notifs->defaultExpireTimeout()); setAutoExpire(notifs->expire()); } QQmlListProperty NotificationStore::rows() { return QQmlListProperty(this, &m_rows); } QQmlListProperty NotificationStore::groups() { return QQmlListProperty(this, &m_groups); } QQmlListProperty NotificationStore::popupGroups() { return QQmlListProperty(this, &m_popupGroups); } NotificationRow* NotificationStore::insert( const QJSValue& notification, bool popup) { const auto wrapped = notification.toVariant().value(); 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; 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(); } } 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(); const auto expireTimeout = notification->property("expireTimeout"); row->m_expireTimeoutMs = expireTimeout.isValid() ? static_cast(expireTimeout.toDouble()) : -1; } 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 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); }); static const QMetaMethod slot = [] { const QMetaObject& storeMeta = NotificationStore::staticMetaObject; return storeMeta.method( storeMeta.indexOfSlot("handleLiveUpdate()")); }(); static const QList mirrored = { "summaryChanged", "bodyChanged", "appNameChanged", "appIconChanged", "imageChanged", "urgencyChanged", "residentChanged", "hasActionIconsChanged", "actionsChanged", "hintsChanged", "expireTimeoutChanged", }; 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 auto expireTimeout = it.key()->property("expireTimeout"); row->m_expireTimeoutMs = expireTimeout.isValid() ? static_cast(expireTimeout.toDouble()) : -1; 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); } 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); } } if (row->m_popupGroup != nullptr) { touchExpiry(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 NotificationStore::parseActions( const QJSValue& actions, NotificationRow* row) { QList 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(i)) .toVariant() .value(); 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) { 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(); } removeRow(row); } void NotificationStore::removeRow(NotificationRow* row) { if (!m_rows.removeOne(row)) { return; } removePopupGroupRow(row); removeGroupRow(row); m_dirty.remove(row); erase_if(m_liveRows, [row](std::pair e) { return e.second == row; }); 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 { 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(); } 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; touchExpiry(row); group->addRow(row); } void NotificationStore::expireGroup(NotificationGroup* group) { const auto rows = group->m_notifs; // setPopup() mutates the group's list for (auto* row : rows) { row->setPopup(false); } } 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(); } int NotificationStore::expireTimeoutMs(const NotificationRow* row) const { if (row->m_expireTimeoutMs == 0) { return 0; } if (row->m_expireTimeoutMs < 0) { return m_popupTimeout; } return static_cast((std::min)(row->m_expireTimeoutMs, MAX_EXPIRE_MS)); } int NotificationStore::groupExpireTimeoutMs( const NotificationGroup* group) const { const auto* newest = group->m_notifs.value(0); return newest != nullptr ? expireTimeoutMs(newest) : m_popupTimeout; } void NotificationStore::touchExpiry(NotificationRow* row) { if (!m_autoExpire || row->m_popupGroup == nullptr) { return; } row->m_popupGroup->resetExpiry(expireTimeoutMs(row)); } 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 = { "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; } } 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; 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(); row->m_popup = false; const auto time = object.value("time"); if (time.isDouble()) { row->m_timeMs = static_cast(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(); } 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(); } 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; } 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); 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")) { iconName = id; theme = iconTheme(); } else { auto* engine = qmlEngine(this); if (engine == nullptr) { return; } auto* base = const_cast( engine->imageProvider(url.host())); provider = dynamic_cast(base); if (provider == nullptr) { return; } providerId = id; switch (provider->imageType()) { case QQuickImageProvider::Image: usePixmap = false; break; case QQuickImageProvider::Pixmap: usePixmap = true; break; default: return; } inlineImage = usePixmap ? provider->requestPixmap(providerId, nullptr, QSize()) .toImage() : provider->requestImage(providerId, nullptr, QSize()); if (inlineImage.isNull()) { return; } } } const QPointer guard(row); auto* watcher = new QFutureWatcher>(this); QObject::connect( watcher, &QFutureWatcher>::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(); } 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 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(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(); } return std::make_pair(digest, png); })); } void NotificationStore::storeImageBlob( const QPointer& 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(); if (m_autoExpire) { const auto groups = m_popupGroups; for (auto* group : groups) { if (group->expired(now)) { expireGroup(group); } else { group->refreshExpiry(now); } } } 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