From e12655fc3066c24b692c512f9dba1f74511ef03b Mon Sep 17 00:00:00 2001 From: Ivy233 Date: Tue, 11 Aug 2026 22:24:29 +0800 Subject: [PATCH] fix: move notification expire timer to the bubble frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification expire timer previously ran in the notification server (worker thread) and was started when the notification was received, so a notification waiting in a long display queue could expire before its bubble was shown. The timeout is now owned by the bubble frontend and only starts once the bubble is actually displayed. 1. Add an ExpireTimer process-wide singleton that tracks each notification id's deadline with one shared single-shot QTimer. It exposes operation-based APIs (start/stop/setBlockedId) instead of lower-level get/set ones and derives the effective timeout from the entity internally, so no QTimer is allocated per notification and nothing leaks on expiry. stop() suspends a countdown by remembering its absolute deadline and start() resumes it, so a notification moving between the bubble and the staging area never restarts its countdown. 2. BubbleModel starts the expire timer when a bubble is shown (insert/replace) using the client expireTimeout (0: never expire, -1: 5000ms default, Critical urgency: never expire). On expiry it emits bubbleExpired(id, bubbleId). setBlockedId pauses/resumes the countdown so hovering keeps the bubble on screen, keeping the 1s grace after unhover. 3. BubblePanel closes the expired bubble and calls notificationClosed(id, bubbleId, Expired) on the server. 4. The notification center staging model reuses the same timer so notifications shown in the staging area also time out. 5. Make NotificationManager::notificationClosed idempotent: a notification is tracked by both the bubble and the staging expire timers, so the close is reported only while the entity still exists, preventing a notification from being closed twice. Critical notifications never expire on their own, enforced both when computing the frontend timeout and on the server. 6. Remove the server-side timeout bookkeeping: setBlockClosedId, pushPendingEntity, onHandingPendingEntities, removePendingEntity and the pending-timeout QTimer. 7. Expose BubbleItem::entity() and NotifyEntity::timeout()/urgency() for the frontend timeout computation. 8. Remove the obsolete SetBlockClosedId tests. Log: Fixed notifications expiring before their bubble was displayed. Influence: 1. Send several notifications at once and verify each bubble stays for the full expire timeout. 2. Verify critical notifications and expireTimeout 0 never close. 3. Hover a bubble and verify it does not expire, then closes 1s after unhover. 4. Verify an expired notification is closed only once and moved to the notification center, and the DBus NotificationClosed(Expired) signal is emitted once. 5. Open the notification center and verify staged notifications expire after their timeout and leave the staging area. 6. Run the notification server unit tests. fix: 将通知过期计时迁移到气泡前端 通知过期计时此前在通知服务器(工作线程)中运行,收到通知时即启动,导致 长显示队列中的通知可能在显示前就已过期。现将超时逻辑交由气泡前端持有, 气泡真正显示后才开始计时。 1. 新增 ExpireTimer 进程级单例:用一个共享的单次 QTimer 管理每个通知 id 的 过期时间,提供 start/stop/setBlockedId 操作化接口,并由实体内部计算有效 超时时间,不再为每个通知分配 QTimer,到期后也不会泄漏对象。stop() 通过 记住绝对截止时间挂起倒计时,start() 恢复它,因此通知在气泡与暂存区之间 切换时不会重新计时。 2. BubbleModel 在气泡显示(插入/替换)时根据客户端 expireTimeout 启动过期 计时(0:永不过期,-1:默认 5000ms,Critical 优先级:永不过期),到期时 发出 bubbleExpired(id, bubbleId)。setBlockedId 暂停/恢复计时,使悬停时 气泡不关闭,并保留取消悬停后 1s 缓冲。 3. 到期后 BubblePanel 关闭气泡并调用服务器 notificationClosed(id, bubbleId, Expired)。 4. 通知中心暂存模型复用同一计时器,暂存区展示的通知同样会超时。 5. 使 NotificationManager::notificationClosed 幂等:同一通知会被气泡与暂存 模型的过期计时同时跟踪,因此在实体仍存在时才上报关闭,避免通知被关闭 两次。Critical 通知永不自动过期,前端超时计算与服务端均强制执行。 6. 移除服务端超时簿记:setBlockClosedId、pushPendingEntity、 onHandingPendingEntities、removePendingEntity 以及 pending-timeout 定时器。 7. 为前端超时计算暴露 BubbleItem::entity() 与 NotifyEntity::timeout()/ urgency()。 8. 移除过时的 SetBlockClosedId 测试。 Log: 修复通知在气泡显示前就过期的问题。 Influence: 1. 一次性发送多条通知,验证每个气泡都能保持完整的过期时间。 2. 验证 Critical 通知与 expireTimeout 为 0 的通知永不过期。 3. 悬停气泡验证其不关闭,取消悬停 1s 后关闭。 4. 验证过期通知只被关闭一次并进入通知中心,DBus NotificationClosed(Expired) 信号只发送一次。 5. 打开通知中心,验证暂存区的通知到期后超时并移出暂存区。 6. 运行通知服务器单元测试。 PMS: BUG-372279 --- panels/notification/CMakeLists.txt | 2 + panels/notification/bubble/bubbleitem.cpp | 5 + panels/notification/bubble/bubbleitem.h | 1 + panels/notification/bubble/bubblemodel.cpp | 50 +++-- panels/notification/bubble/bubblemodel.h | 12 +- panels/notification/bubble/bubblepanel.cpp | 10 +- .../center/notifystagingmodel.cpp | 20 +- panels/notification/common/expiretimer.cpp | 191 ++++++++++++++++++ panels/notification/common/expiretimer.h | 76 +++++++ panels/notification/common/notifyentity.cpp | 10 + panels/notification/common/notifyentity.h | 4 + .../server/notificationmanager.cpp | 132 +----------- .../notification/server/notificationmanager.h | 9 - .../server/notifyserverapplet.cpp | 8 +- .../notification/server/notifyserverapplet.h | 1 - .../server/notifyserverapplet_test.cpp | 22 -- 16 files changed, 371 insertions(+), 182 deletions(-) create mode 100644 panels/notification/common/expiretimer.cpp create mode 100644 panels/notification/common/expiretimer.h diff --git a/panels/notification/CMakeLists.txt b/panels/notification/CMakeLists.txt index b7c6414eb..f7a60ff03 100644 --- a/panels/notification/CMakeLists.txt +++ b/panels/notification/CMakeLists.txt @@ -22,6 +22,8 @@ add_library(ds-notification-shared SHARED ${CMAKE_SOURCE_DIR}/panels/notification/common/dbaccessor.cpp ${CMAKE_SOURCE_DIR}/panels/notification/common/notifysetting.h ${CMAKE_SOURCE_DIR}/panels/notification/common/notifysetting.cpp + ${CMAKE_SOURCE_DIR}/panels/notification/common/expiretimer.h + ${CMAKE_SOURCE_DIR}/panels/notification/common/expiretimer.cpp ) set_target_properties(ds-notification-shared PROPERTIES diff --git a/panels/notification/bubble/bubbleitem.cpp b/panels/notification/bubble/bubbleitem.cpp index 60d48a6d7..695a8d23b 100644 --- a/panels/notification/bubble/bubbleitem.cpp +++ b/panels/notification/bubble/bubbleitem.cpp @@ -51,6 +51,11 @@ qint64 BubbleItem::id() const return m_entity.id(); } +const NotifyEntity &BubbleItem::entity() const +{ + return m_entity; +} + uint BubbleItem::bubbleId() const { return m_entity.bubbleId(); diff --git a/panels/notification/bubble/bubbleitem.h b/panels/notification/bubble/bubbleitem.h index 1b28f0aa8..417bd5ba2 100644 --- a/panels/notification/bubble/bubbleitem.h +++ b/panels/notification/bubble/bubbleitem.h @@ -21,6 +21,7 @@ class BubbleItem : public QObject public: void setEntity(const NotifyEntity &entity); + const NotifyEntity &entity() const; public: qint64 id() const; diff --git a/panels/notification/bubble/bubblemodel.cpp b/panels/notification/bubble/bubblemodel.cpp index c9bbee6fb..71a5f9b55 100644 --- a/panels/notification/bubble/bubblemodel.cpp +++ b/panels/notification/bubble/bubblemodel.cpp @@ -7,6 +7,7 @@ #include #include "bubbleitem.h" +#include "expiretimer.h" #include #include @@ -20,6 +21,8 @@ Q_DECLARE_LOGGING_CATEGORY(notifyLog) namespace notification { +static const int BlockItemTimeout = 1000; + BubbleModel::BubbleModel(QObject *parent) : QAbstractListModel(parent) , m_updateTimeTipTimer(new QTimer(this)) @@ -41,6 +44,12 @@ BubbleModel::BubbleModel(QObject *parent) m_processPendingTimer->start(); } }); + connect(ExpireTimer::instance(), &ExpireTimer::expired, this, [this](qint64 id, uint bubbleId) { + // A bubble that has been pushed off the display (overflow) is no longer + // in the model, but its countdown still finishes and the notification + // should still be closed. + Q_EMIT bubbleExpired(id, bubbleId); + }); connect(NotifySetting::instance(), &NotifySetting::contentRowCountChanged, this, &BubbleModel::updateContentRowCount); connect(NotifySetting::instance(), &NotifySetting::bubbleCountChanged, this, &BubbleModel::updateBubbleCount); @@ -82,6 +91,10 @@ void BubbleModel::insertBubble(BubbleItem *bubble) beginInsertRows(QModelIndex(), 0, 0); m_bubbles.prepend(bubble); endInsertRows(); + + // A non-positive interval (Critical urgency or expireTimeout 0) means the + // bubble never expires on its own. + ExpireTimer::instance()->start(bubble->entity()); } bool BubbleModel::isReplaceBubble(const BubbleItem *bubble) const @@ -95,28 +108,19 @@ BubbleItem *BubbleModel::replaceBubble(BubbleItem *bubble) const auto replaceIndex = replaceBubbleIndex(bubble); const auto oldBubble = m_bubbles[replaceIndex]; + ExpireTimer::instance()->stop(oldBubble->id()); + m_bubbles.replace(replaceIndex, bubble); Q_EMIT dataChanged(index(replaceIndex), index(replaceIndex)); - return oldBubble; -} + ExpireTimer::instance()->start(bubble->entity()); -void BubbleModel::clear() -{ - if (m_processPendingTimer) { - m_processPendingTimer->stop(); - } - qDeleteAll(m_pendingBubbles); - m_pendingBubbles.clear(); + // If the replaced bubble was the hovered one, keep blocking the new one and + // keep m_blockedId in sync with the timer's paused state. + if (m_blockedId == oldBubble->id()) + setBlockedId(bubble->id()); - if (m_bubbles.count() <= 0) - return; - beginResetModel(); - qDeleteAll(m_bubbles); - m_bubbles.clear(); - endResetModel(); - - m_updateTimeTipTimer->stop(); + return oldBubble; } QList BubbleModel::items() const @@ -131,9 +135,9 @@ void BubbleModel::remove(int index) beginRemoveRows(QModelIndex(), index, index); auto bubble = m_bubbles.takeAt(index); + ExpireTimer::instance()->stop(bubble->id()); bubble->deleteLater(); endRemoveRows(); - } void BubbleModel::remove(const BubbleItem *bubble) @@ -298,4 +302,14 @@ void BubbleModel::updateContentRowCount(int rowCount) Q_EMIT dataChanged(index(0), index(m_bubbles.size() - 1), {BubbleModel::ContentRowCount}); } } + +void BubbleModel::setBlockedId(qint64 id) +{ + if (id == m_blockedId) + return; + + m_blockedId = id; + ExpireTimer::instance()->setBlockedId(id, BlockItemTimeout); } + +} // notification diff --git a/panels/notification/bubble/bubblemodel.h b/panels/notification/bubble/bubblemodel.h index b9b8f6203..c538644ec 100644 --- a/panels/notification/bubble/bubblemodel.h +++ b/panels/notification/bubble/bubblemodel.h @@ -8,6 +8,7 @@ #include "notifyentity.h" #include +#include #include class QTimer; @@ -38,6 +39,10 @@ class BubbleModel : public QAbstractListModel explicit BubbleModel(QObject *parent = nullptr); ~BubbleModel() override; +Q_SIGNALS: + // Emitted when a bubble reaches its expire timeout and should be closed. + void bubbleExpired(qint64 id, uint bubbleId); + public: void push(BubbleItem *bubble); @@ -49,7 +54,10 @@ class BubbleModel : public QAbstractListModel Q_INVOKABLE void remove(int index); void remove(const BubbleItem *bubble); BubbleItem *removeById(qint64 id); - void clear(); + + // Pause/resume the expire timer of the hovered bubble so hovering + // keeps the bubble on screen (0 clears the blocked bubble). + void setBlockedId(qint64 id); BubbleItem *bubbleItem(int bubbleIndex) const; @@ -68,11 +76,11 @@ class BubbleModel : public QAbstractListModel void updateBubbleTimeTip(); void updateContentRowCount(int rowCount); -private: QTimer *m_updateTimeTipTimer = nullptr; QTimer *m_processPendingTimer = nullptr; QList m_bubbles; QQueue m_pendingBubbles; + qint64 m_blockedId = NotifyEntity::InvalidId; int m_maxKeep{5}; int m_contentRowCount{6}; }; diff --git a/panels/notification/bubble/bubblepanel.cpp b/panels/notification/bubble/bubblepanel.cpp index 469fe67f7..b9a48f276 100644 --- a/panels/notification/bubble/bubblepanel.cpp +++ b/panels/notification/bubble/bubblepanel.cpp @@ -53,6 +53,14 @@ bool BubblePanel::init() connect(m_bubbles, &BubbleModel::rowsInserted, this, &BubblePanel::onBubbleCountChanged); connect(m_bubbles, &BubbleModel::rowsRemoved, this, &BubblePanel::onBubbleCountChanged); + // The bubble model runs one expire timer per shown bubble. When a bubble + // times out, close it and notify the server so it moves the notification + // from the in-memory store to the center database and emits the signals. + connect(m_bubbles, &BubbleModel::bubbleExpired, this, [this](qint64 id, uint bubbleId) { + closeBubble(id); + QMetaObject::invokeMethod(m_notificationServer, "notificationClosed", Qt::AutoConnection, + Q_ARG(qint64, id), Q_ARG(uint, bubbleId), Q_ARG(uint, NotifyEntity::Expired)); + }); return true; } @@ -217,7 +225,7 @@ void BubblePanel::setEnabled(bool newEnabled) void BubblePanel::setHoveredId(qint64 id) { - QMetaObject::invokeMethod(m_notificationServer, "setBlockClosedId", Qt::DirectConnection, Q_ARG(qint64, id)); + m_bubbles->setBlockedId(id); } } diff --git a/panels/notification/center/notifystagingmodel.cpp b/panels/notification/center/notifystagingmodel.cpp index 303bfc75c..ed54c3b26 100644 --- a/panels/notification/center/notifystagingmodel.cpp +++ b/panels/notification/center/notifystagingmodel.cpp @@ -8,6 +8,7 @@ #include #include "dataaccessorproxy.h" +#include "expiretimer.h" #include "notifyaccessor.h" #include "notifyentity.h" #include "notifyitem.h" @@ -25,6 +26,11 @@ NotifyStagingModel::NotifyStagingModel(QObject *parent) connect(NotifyAccessor::instance(), &NotifyAccessor::stagingEntityReceived, this, &NotifyStagingModel::doEntityReceived); connect(NotifyAccessor::instance(), &NotifyAccessor::stagingEntityClosed, this, &NotifyStagingModel::onEntityClosed); connect(NotifySetting::instance(), &NotifySetting::contentRowCountChanged, this, &NotifyStagingModel::updateContentRowCount); + + connect(ExpireTimer::instance(), &ExpireTimer::expired, this, [this](qint64 id, uint bubbleId) { + Q_UNUSED(bubbleId) + closeNotify(id, NotifyEntity::Expired); + }); } void NotifyStagingModel::close() @@ -63,6 +69,10 @@ void NotifyStagingModel::push(const NotifyEntity &entity) updateOverlapCount(count); } + // A non-positive interval (Critical urgency or expireTimeout 0) means the + // notification never expires on its own. + ExpireTimer::instance()->start(entity); + if (m_refreshTimer < 0) { m_refreshTimer = startTimer(std::chrono::milliseconds(1000)); } @@ -90,6 +100,8 @@ void NotifyStagingModel::remove(qint64 id) { qDebug(notifyLog) << "Remove notify by id" << id; + ExpireTimer::instance()->stop(id); + int row = -1; for (int i = 0; i < m_appNotifies.size(); i++) { auto item = m_appNotifies[i]; @@ -146,6 +158,7 @@ void NotifyStagingModel::remove(qint64 id) auto notify = new AppNotifyItem(newEntity); m_appNotifies.insert(insertedIndex, notify); endInsertRows(); + ExpireTimer::instance()->start(newEntity); } } updateOverlapCount(entities.size()); @@ -172,6 +185,9 @@ void NotifyStagingModel::open() auto notify = new AppNotifyItem(entities.at(i)); m_appNotifies << notify; } + for (const auto &entity : entities) { + ExpireTimer::instance()->start(entity); + } updateOverlapCount(entities.size()); endResetModel(); @@ -250,8 +266,10 @@ void NotifyStagingModel::replace(const NotifyEntity &entity) { for (int i = 0; i < m_appNotifies.size(); i++) { auto item = m_appNotifies[i]; - if (item->id() == entity.bubbleId()) { + if (item->id() == entity.id()) { + ExpireTimer::instance()->stop(entity.id()); item->setEntity(entity); + ExpireTimer::instance()->start(entity); const auto index = this->index(i, 0, {}); dataChanged(index, index); break; diff --git a/panels/notification/common/expiretimer.cpp b/panels/notification/common/expiretimer.cpp new file mode 100644 index 000000000..47c727f5a --- /dev/null +++ b/panels/notification/common/expiretimer.cpp @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "expiretimer.h" + +#include +#include + +#include +#include + +namespace notification { + +static const int DefaultTimeoutMSecs = 5000; + +// Upper bound on how many suspended deadlines stop() may remember. Ids that are +// stopped and never started again (genuinely removed notifications) are +// evicted oldest-first once the cap is reached, so they cannot leak unboundedly. +static const int MaxRememberedIds = 64; + +// Effective expire timeout in milliseconds for a notification. +// Returns 0 for "never expire" (Critical urgency or expireTimeout == 0) and +// falls back to the server default of 5000 ms for expireTimeout == -1. +static int effectiveTimeout(const NotifyEntity &entity) +{ + if (entity.urgency() == NotifyEntity::Critical || entity.timeout() == 0) + return 0; + + return entity.timeout() == -1 ? DefaultTimeoutMSecs : entity.timeout(); +} + +ExpireTimer::ExpireTimer(QObject *parent) + : QObject(parent) + , m_timer(new QTimer(this)) +{ + m_timer->setSingleShot(true); + connect(m_timer, &QTimer::timeout, this, &ExpireTimer::onTimeout); +} + +ExpireTimer *ExpireTimer::instance() +{ + static ExpireTimer expireTimer; + return &expireTimer; +} + +void ExpireTimer::start(const NotifyEntity &entity) +{ + const auto id = entity.id(); + const int interval = effectiveTimeout(entity); + if (interval <= 0) { + // Never expire: cancel any pending, paused or suspended countdown. + if (m_pausedId == id) + m_pausedId = NotifyEntity::InvalidId; + m_deadlines.remove(id); + m_retired.remove(id); + m_bubbleIds.remove(id); + schedule(); + return; + } + + // Keep the paused countdown while the id is hovered. + if (m_pausedId == id) + return; + + // A notification displayed in both the bubble and the staging area shares + // one deadline: starting an already tracked id keeps the original countdown + // instead of restarting it. + if (m_deadlines.contains(id)) + return; + + const auto now = QDateTime::currentMSecsSinceEpoch(); + + // Resume a countdown suspended by stop() instead of restarting it, so a + // notification that moved between the bubble and the staging area keeps + // its original expire deadline (or expires immediately if it already + // passed while suspended). + if (const auto it = m_retired.constFind(id); it != m_retired.cend()) { + m_deadlines.insert(id, qMax(now, it.value())); + m_retired.erase(it); + m_bubbleIds.insert(id, entity.bubbleId()); + schedule(); + return; + } + + m_deadlines.insert(id, now + interval); + m_bubbleIds.insert(id, entity.bubbleId()); + schedule(); +} + +void ExpireTimer::stop(qint64 id) +{ + // A hovered countdown that is stopped keeps its remaining time too. + if (m_pausedId == id) { + m_retired.insert(id, QDateTime::currentMSecsSinceEpoch() + m_pausedRemaining); + m_pausedId = NotifyEntity::InvalidId; + m_pausedRemaining = 0; + } + + if (const auto it = m_deadlines.constFind(id); it != m_deadlines.cend()) { + // Suspend rather than forget so a later start() resumes the same + // countdown instead of restarting it. + m_retired.insert(id, it.value()); + m_deadlines.erase(it); + + // Bound the memory of ids that are stopped and never started again. + if (m_retired.size() > MaxRememberedIds) { + const auto minIt = std::min_element(m_retired.constBegin(), m_retired.constEnd(), + [](const qint64 &lhs, const qint64 &rhs) { return lhs < rhs; }); + m_retired.erase(minIt); + } + schedule(); + } + m_bubbleIds.remove(id); +} + +void ExpireTimer::setBlockedId(qint64 id, int graceRemaining) +{ + if (id == m_pausedId) + return; + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + + // The hover moved away from the previously blocked id: resume its + // countdown with at least graceRemaining ms left so the bubble lingers + // briefly after the hover ends. + if (m_pausedId != NotifyEntity::InvalidId) { + m_deadlines.insert(m_pausedId, now + qMax(m_pausedRemaining, graceRemaining)); + schedule(); + } + + m_pausedId = id; + + // Block the newly hovered id by freezing its remaining time. If it has no + // running countdown (never-expire or not yet started) there is nothing to + // pause; clear the block so unhover does not later resume a ghost deadline. + const auto it = m_deadlines.constFind(id); + if (it == m_deadlines.cend()) { + m_pausedId = NotifyEntity::InvalidId; + return; + } + + const qint64 remaining = qMax(0, it.value() - now); + m_pausedRemaining = static_cast(qMin(remaining, std::numeric_limits::max())); + m_deadlines.erase(it); + schedule(); +} + +void ExpireTimer::schedule() +{ + if (m_deadlines.isEmpty()) { + m_timer->stop(); + return; + } + + auto it = std::min_element(m_deadlines.cbegin(), m_deadlines.cend(), + [](const qint64 &lhs, const qint64 &rhs) { return lhs < rhs; }); + const qint64 remaining = qMax(0, it.value() - QDateTime::currentMSecsSinceEpoch()); + m_timer->start(static_cast(remaining)); +} + +void ExpireTimer::onTimeout() +{ + const auto now = QDateTime::currentMSecsSinceEpoch(); + const QList expiredIds = [this, now] { + QList ids; + for (auto it = m_deadlines.cbegin(); it != m_deadlines.cend(); ++it) { + if (it.value() <= now) + ids.append(it.key()); + } + return ids; + }(); + + for (const auto &id : expiredIds) { + if (m_deadlines.remove(id) > 0) { + // The id expired: its countdown is over for good, so forget it in + // every table (active, suspended, hovered and bubble id). + m_retired.remove(id); + const auto bubbleId = m_bubbleIds.take(id); + if (m_pausedId == id) { + m_pausedId = NotifyEntity::InvalidId; + m_pausedRemaining = 0; + } + Q_EMIT expired(id, bubbleId); + } + } + + schedule(); +} + +} diff --git a/panels/notification/common/expiretimer.h b/panels/notification/common/expiretimer.h new file mode 100644 index 000000000..25e22b3cc --- /dev/null +++ b/panels/notification/common/expiretimer.h @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include "notifyentity.h" + +class QTimer; + +namespace notification { + +/** + * @brief Process-wide singleton that tracks the expire deadline of every + * displayed notification (bubble or staging area) with one shared + * single-shot QTimer. + * + * Each notification id keeps its own deadline and the nearest one drives the + * shared QTimer; when a deadline passes, expired() is emitted once with the + * notification id and its bubble id, and the id is forgotten. Starting an + * already tracked id keeps the original deadline, and stop() suspends a + * countdown by remembering its absolute deadline, so a notification shown in + * both the bubble and the staging area (or moving between them) never restarts + * its countdown. The effective timeout is derived internally from the entity, + * so callers only describe what to do (start, stop, setBlockedId) and never + * have to compute or query timeouts. No QTimer is allocated per id and nothing + * leaks when an id expires or is stopped. Only one id can be blocked at a time + * (the hovered bubble); the hover handling is encapsulated in setBlockedId so + * callers do not deal with pause/resume details. + */ +class ExpireTimer : public QObject +{ + Q_OBJECT +public: + static ExpireTimer *instance(); + + // Starts (or keeps) the countdown of the entity's id based on its urgency + // and expire timeout. A non-positive timeout (Critical urgency or + // expireTimeout 0) cancels any pending or paused countdown, i.e. the + // notification never expires. If the id was previously stopped, the + // original countdown is resumed instead of being restarted. + void start(const NotifyEntity &entity); + // Stops tracking id. The remaining countdown is suspended (not forgotten), + // so a later start() with the same id resumes it. + void stop(qint64 id); + // Blocks the hovered id from expiring, keeping its remaining time. Only one + // id is blocked at a time: switching to another id resumes the previous one + // with at least graceRemaining ms left (keeping the bubble visible for a + // short grace period after the hover moves away). Passing InvalidId clears + // the block. + void setBlockedId(qint64 id, int graceRemaining = 0); + +Q_SIGNALS: + // Emitted once when the deadline of id passes. + void expired(qint64 id, uint bubbleId); + +private: + explicit ExpireTimer(QObject *parent = nullptr); + + void schedule(); + void onTimeout(); + + QTimer *m_timer = nullptr; + QHash m_deadlines; + // Absolute deadlines of ids stopped by stop(); restored by start() so a + // context switch (bubble <-> staging) resumes the countdown. + QHash m_retired; + QHash m_bubbleIds; + qint64 m_pausedId = NotifyEntity::InvalidId; + int m_pausedRemaining = 0; +}; + +} diff --git a/panels/notification/common/notifyentity.cpp b/panels/notification/common/notifyentity.cpp index 7c55cdc82..a7c832096 100644 --- a/panels/notification/common/notifyentity.cpp +++ b/panels/notification/common/notifyentity.cpp @@ -236,6 +236,16 @@ bool NotifyEntity::isReplace() const return d->replacesId != NoReplaceId; } +int NotifyEntity::timeout() const +{ + return d->expireTimeout; +} + +int NotifyEntity::urgency() const +{ + return d->hints.value("urgency").toInt(); +} + qint64 NotifyEntity::cTime() const { return d->cTime; diff --git a/panels/notification/common/notifyentity.h b/panels/notification/common/notifyentity.h index 967280ddb..f3f99200f 100644 --- a/panels/notification/common/notifyentity.h +++ b/panels/notification/common/notifyentity.h @@ -81,6 +81,10 @@ class NotifyEntity void setReplacesId(uint replacesId); bool isReplace() const; + // Expire timeout in milliseconds passed in by the client (-1 means server default). + int timeout() const; + int urgency() const; + qint64 cTime() const; void setCTime(qint64 cTime); diff --git a/panels/notification/server/notificationmanager.cpp b/panels/notification/server/notificationmanager.cpp index 74c564197..e8fc0d09a 100644 --- a/panels/notification/server/notificationmanager.cpp +++ b/panels/notification/server/notificationmanager.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -37,8 +36,6 @@ Q_DECLARE_LOGGING_CATEGORY(notifyLog) namespace notification { static const uint NoReplacesId = 0; -static const int DefaultTimeOutMSecs = 5000; -static const int BlockItemTimeout = 1000; static const QString NotificationsDBusService = "org.freedesktop.Notifications"; static const QString NotificationsDBusPath = "/org/freedesktop/Notifications"; static const QString DDENotifyDBusServer = "org.deepin.dde.Notification1"; @@ -50,11 +47,7 @@ NotificationManager::NotificationManager(QObject *parent) : QObject(parent) , m_persistence(DataAccessorProxy::instance()) , m_setting(new NotificationSetting(this)) - , m_pendingTimeout(new QTimer(this)) { - m_pendingTimeout->setSingleShot(true); - connect(m_pendingTimeout, &QTimer::timeout, this, &NotificationManager::onHandingPendingEntities); - DataAccessorProxy::instance()->setSource(DBAccessor::instance()); DAppletBridge bridge("org.deepin.ds.dde-apps"); @@ -164,6 +157,16 @@ void NotificationManager::actionInvoked(qint64 id, uint bubbleId, const QString void NotificationManager::notificationClosed(qint64 id, uint bubbleId, uint reason) { qDebug(notifyLog) << "Close notification id" << id << ", reason" << reason; + + const auto entity = m_persistence->fetchEntity(id); + // A notification can be tracked by more than one expire timer (the bubble + // frontend and the notification center staging model both schedule a timeout + // for the same id), so it may already be closed or removed by the time this + // is reached. Report the close only once to avoid emitting NotificationClosed + // twice for a single notification. + if (!entity.isValid()) + return; + updateEntityProcessed(id, reason); Q_EMIT NotificationClosed(bubbleId, reason); @@ -296,22 +299,9 @@ uint NotificationManager::Notify(const QString &appName, uint replacesId, const return 0; } - if (entity.isReplace() && m_persistence->fetchLastEntity(entity.bubbleId()).isValid()) { - removePendingEntity(entity); - } - emitRecordCountChanged(); Q_EMIT NotificationStateChanged(entity.id(), entity.processedType()); - - bool critical = false; - if (auto iter = hints.find("urgency"); iter != hints.end()) { - critical = iter.value().toUInt() == NotifyEntity::Critical; - } - // 0: never expire. -1: DefaultTimeOutMSecs - if (expireTimeout != 0 && !critical) { - pushPendingEntity(entity, expireTimeout); - } } tryPlayNotificationSound(entity, appId, dndMode); @@ -372,29 +362,6 @@ QVariant NotificationManager::GetSystemInfo(uint configItem) return m_setting->systemValue(static_cast(configItem)); } -void NotificationManager::setBlockClosedId(qint64 id) -{ - if (id == m_blockClosedId) { - return; - } - - if(m_blockClosedId != NotifyEntity::InvalidId) { - auto findIter = std::find_if(m_pendingTimeoutEntities.begin(), m_pendingTimeoutEntities.end(), [this](const NotifyEntity &entity) { - return entity.id() == m_blockClosedId; - }); - - const auto current = QDateTime::currentMSecsSinceEpoch(); - if (findIter != m_pendingTimeoutEntities.end()) { - if (current > findIter.key() - BlockItemTimeout) { - qDebug(notifyLog) << "Delay close bubble id:" << m_blockClosedId << "for the new block bubble id:" << id; - m_pendingTimeoutEntities.insert(current + BlockItemTimeout, findIter.value()); - m_pendingTimeoutEntities.erase(findIter); - } - } - } - m_blockClosedId = id; - onHandingPendingEntities(); -} bool NotificationManager::isDoNotDisturb() const { @@ -498,21 +465,6 @@ void NotificationManager::emitRecordCountChanged() emit RecordCountChanged(count); } -void NotificationManager::pushPendingEntity(const NotifyEntity &entity, int expireTimeout) -{ - const int interval = expireTimeout == -1 ? DefaultTimeOutMSecs : expireTimeout; - - qint64 point = QDateTime::currentMSecsSinceEpoch() + interval; - m_pendingTimeoutEntities.insert(point, entity); - - if (m_lastTimeoutPoint > point) { - m_lastTimeoutPoint = point; - auto newInterval = m_lastTimeoutPoint - QDateTime::currentMSecsSinceEpoch(); - m_pendingTimeout->setInterval(newInterval); - m_pendingTimeout->start(); - } -} - void NotificationManager::updateEntityProcessed(qint64 id, uint reason) { auto entity = m_persistence->fetchEntity(id); @@ -546,7 +498,6 @@ void NotificationManager::updateEntityProcessed(const NotifyEntity &entity) Q_EMIT NotificationStateChanged(entity.id(), entity.processedType()); - removePendingEntity(entity); emitRecordCountChanged(); } @@ -707,69 +658,6 @@ void NotificationManager::initScreenLockedState() "Visible", this, SLOT(onScreenLockedChanged(bool))); } -void NotificationManager::onHandingPendingEntities() -{ - QList timeoutEntities; - - const auto current = QDateTime::currentMSecsSinceEpoch(); - for (auto iter = m_pendingTimeoutEntities.begin(); iter != m_pendingTimeoutEntities.end();) { - const auto point = iter.key(); - if (point > current) { - iter++; - continue; - } - - const auto entity = iter.value();; - timeoutEntities << entity; - iter = m_pendingTimeoutEntities.erase(iter); - } - - // update pendingTimeout to deal with m_pendingTimeoutEntities - if (!m_pendingTimeoutEntities.isEmpty()) { - auto points = m_pendingTimeoutEntities.keys(); - std::sort(points.begin(), points.end()); - // find last point to restart pendingTimeout - m_lastTimeoutPoint = points.first(); - auto newInterval = m_lastTimeoutPoint - current; - // let timer start in main thread - QMetaObject::invokeMethod(m_pendingTimeout, "start", Qt::QueuedConnection, Q_ARG(int, newInterval)); - } else { - // reset m_lastTimeoutPoint - m_lastTimeoutPoint = std::numeric_limits::max(); - } - - for (const auto &item : timeoutEntities) { - // Validate entity before processing timeout to prevent race conditions - if (!item.isValid()) { - qWarning(notifyLog) << "Skipping timeout processing for invalid entity id:" << item.id() << "appName:" << item.appName() - << "cTime:" << item.cTime(); - continue; - } - - if (item.id() == m_blockClosedId) { - qDebug(notifyLog) << "bubble id:" << item.bubbleId() << "entity id:" << item.id(); - m_pendingTimeoutEntities.insert(current, item); - continue; - } - - qDebug(notifyLog) << "Expired for the notification " << item.id() << item.appName(); - notificationClosed(item.id(), item.bubbleId(), NotifyEntity::Expired); - } -} - -void NotificationManager::removePendingEntity(const NotifyEntity &entity) -{ - for (auto iter = m_pendingTimeoutEntities.begin(); iter != m_pendingTimeoutEntities.end();) { - const auto item = iter.value(); - if (item == entity || (entity.isReplace() && item.bubbleId() == entity.bubbleId())) { - m_pendingTimeoutEntities.erase(iter); - onHandingPendingEntities(); - break; - } - ++iter; - } -} - void NotificationManager::onScreenLockedChanged(bool screenLocked) { m_screenLocked = screenLocked; diff --git a/panels/notification/server/notificationmanager.h b/panels/notification/server/notificationmanager.h index f2756669d..1d95b4530 100644 --- a/panels/notification/server/notificationmanager.h +++ b/panels/notification/server/notificationmanager.h @@ -7,7 +7,6 @@ #include #include -class QTimer; namespace notification { class NotifyEntity; @@ -68,14 +67,12 @@ public Q_SLOTS: void SetSystemInfo(uint configItem, const QVariant &value); QVariant GetSystemInfo(uint configItem); - void setBlockClosedId(qint64 id); private: bool isDoNotDisturb() const; bool recordNotification(NotifyEntity &entity); void tryPlayNotificationSound(const NotifyEntity &entity, const QString &appId, bool dndMode) const; void emitRecordCountChanged(); - void pushPendingEntity(const NotifyEntity &entity, int expireTimeout); void updateEntityProcessed(qint64 id, uint reason); void updateEntityProcessed(const NotifyEntity &entity); @@ -86,8 +83,6 @@ public Q_SLOTS: void initScreenLockedState(); private slots: - void onHandingPendingEntities(); - void removePendingEntity(const NotifyEntity &entity); void onScreenLockedChanged(bool); private: @@ -96,13 +91,9 @@ private slots: DataAccessor *m_persistence = nullptr; NotificationSetting *m_setting = nullptr; - QTimer *m_pendingTimeout = nullptr; - qint64 m_lastTimeoutPoint = std::numeric_limits::max(); - QMultiHash m_pendingTimeoutEntities; QStringList m_systemApps; QMap m_appNamesMap; int m_cleanupDays = 7; - qint64 m_blockClosedId = 0; }; } // notification diff --git a/panels/notification/server/notifyserverapplet.cpp b/panels/notification/server/notifyserverapplet.cpp index b4de43dfc..54bdca64f 100644 --- a/panels/notification/server/notifyserverapplet.cpp +++ b/panels/notification/server/notifyserverapplet.cpp @@ -76,7 +76,8 @@ void NotifyServerApplet::actionInvoked(qint64 id, const QString &actionKey) void NotifyServerApplet::notificationClosed(qint64 id, uint bubbleId, uint reason) { - QMetaObject::invokeMethod(m_manager, "notificationClosed", Qt::DirectConnection, Q_ARG(qint64, id), Q_ARG(uint, bubbleId), Q_ARG(uint, reason)); + // The manager lives on the worker thread, so deliver the close to it there. + QMetaObject::invokeMethod(m_manager, "notificationClosed", Qt::QueuedConnection, Q_ARG(qint64, id), Q_ARG(uint, bubbleId), Q_ARG(uint, reason)); } QVariant NotifyServerApplet::appValue(const QString &appId, int configItem) @@ -104,11 +105,6 @@ void NotifyServerApplet::removeExpiredNotifications() m_manager->removeExpiredNotifications(); } -void NotifyServerApplet::setBlockClosedId(qint64 id) -{ - m_manager->setBlockClosedId(id); -} - D_APPLET_CLASS(NotifyServerApplet) } diff --git a/panels/notification/server/notifyserverapplet.h b/panels/notification/server/notifyserverapplet.h index 20975e91d..ff8ea57e9 100644 --- a/panels/notification/server/notifyserverapplet.h +++ b/panels/notification/server/notifyserverapplet.h @@ -31,7 +31,6 @@ public Q_SLOTS: void removeNotifications(const QString &appName); void removeNotifications(); void removeExpiredNotifications(); - void setBlockClosedId(qint64 id); private: NotificationManager *m_manager = nullptr; diff --git a/tests/panels/notification/server/notifyserverapplet_test.cpp b/tests/panels/notification/server/notifyserverapplet_test.cpp index 9a0463167..64ba2b46d 100644 --- a/tests/panels/notification/server/notifyserverapplet_test.cpp +++ b/tests/panels/notification/server/notifyserverapplet_test.cpp @@ -34,7 +34,6 @@ class MockNotificationManager : public NotificationManager { MOCK_METHOD(void, removeNotifications, (const QString &appName)); MOCK_METHOD(void, removeNotifications, ()); MOCK_METHOD(void, removeExpiredNotifications, ()); - MOCK_METHOD(void, setBlockClosedId, (qint64 id)); }; // Test fixture for NotifyServerApplet @@ -244,17 +243,6 @@ TEST_F(NotifyServerAppletTest, RemoveExpiredNotificationsTest) { EXPECT_NO_THROW(applet->removeExpiredNotifications()); } -// Test setBlockClosedId -TEST_F(NotifyServerAppletTest, SetBlockClosedIdTest) { - // Initialize applet first - applet->init(); - - qint64 testId = 12345; - - // Test that setBlockClosedId doesn't crash - EXPECT_NO_THROW(applet->setBlockClosedId(testId)); -} - // Test notificationStateChanged signal TEST_F(NotifyServerAppletTest, NotificationStateChangedSignalTest) { // Initialize applet first @@ -315,16 +303,6 @@ TEST_F(NotifyServerAppletTest, NotificationClosedEdgeCasesTest) { EXPECT_NO_THROW(applet->notificationClosed(999999999, 999999, 3)); } -// Test edge cases for setBlockClosedId -TEST_F(NotifyServerAppletTest, SetBlockClosedIdEdgeCasesTest) { - applet->init(); - - // Test with various ID values - EXPECT_NO_THROW(applet->setBlockClosedId(0)); - EXPECT_NO_THROW(applet->setBlockClosedId(-1)); - EXPECT_NO_THROW(applet->setBlockClosedId(9223372036854775807LL)); // max qint64 -} - // Test that applet properly inherits from DApplet TEST_F(NotifyServerAppletTest, InheritanceTest) { EXPECT_TRUE(applet->inherits("ds::DApplet"));