File indexing completed on 2024-05-12 05:10:41

0001 // SPDX-FileCopyrightText: 2021 Claudio Cambra <claudio.cambra@gmail.com>
0002 // SPDX-License-Identifier: LGPL-2.1-or-later
0003 
0004 #include "kalendaralarmclient.h"
0005 #include "../src/calendarsettings.h"
0006 #include "alarmnotification.h"
0007 #include "calendarinterface.h"
0008 #include "logging.h"
0009 
0010 #include <Akonadi/IncidenceChanger>
0011 #include <KIO/ApplicationLauncherJob>
0012 #include <KIdentityManagementCore/Utils>
0013 
0014 #include <KCheckableProxyModel>
0015 #include <KConfigGroup>
0016 #include <KLocalizedString>
0017 #include <KSharedConfig>
0018 
0019 #include <QFileInfo>
0020 
0021 using namespace KCalendarCore;
0022 
0023 namespace
0024 {
0025 static const char mySuspensedGroupName[] = "Suspended";
0026 }
0027 
0028 KalendarAlarmClient::KalendarAlarmClient(QObject *parent)
0029     : QObject(parent)
0030 {
0031     mCheckTimer.setSingleShot(true);
0032     mCheckTimer.setTimerType(Qt::VeryCoarseTimer);
0033 
0034     // Check if Akonadi is already configured
0035     const QString akonadiConfigFile = Akonadi::ServerManager::serverConfigFilePath(Akonadi::ServerManager::ReadWrite);
0036     if (QFileInfo::exists(akonadiConfigFile)) {
0037         // Akonadi is configured, create ETM and friends, which will start Akonadi
0038         // if its not running yet
0039         setupAkonadi();
0040     } else {
0041         // Akonadi has not been set up yet, wait for someone else to start it,
0042         // so that we don't unnecessarily slow session start up
0043         connect(Akonadi::ServerManager::self(), &Akonadi::ServerManager::stateChanged, this, [this](Akonadi::ServerManager::State state) {
0044             if (state == Akonadi::ServerManager::Running) {
0045                 setupAkonadi();
0046             }
0047         });
0048     }
0049 
0050     KConfigGroup alarmGroup(KSharedConfig::openConfig(), QStringLiteral("Alarms"));
0051     mLastChecked = alarmGroup.readEntry("CalendarsLastChecked", QDateTime::currentDateTime().addDays(-9));
0052 
0053     restoreSuspendedFromConfig();
0054 }
0055 
0056 KalendarAlarmClient::~KalendarAlarmClient() = default;
0057 
0058 void KalendarAlarmClient::setupAkonadi()
0059 {
0060     const QStringList mimeTypes{Event::eventMimeType(), Todo::todoMimeType()};
0061     mCalendar = Akonadi::ETMCalendar::Ptr(new Akonadi::ETMCalendar(mimeTypes));
0062     mCalendar->setObjectName(QLatin1StringView("KalendarAC's calendar"));
0063     Akonadi::IncidenceChanger *changer = mCalendar->incidenceChanger();
0064     changer->setShowDialogsOnError(false);
0065     mETM = mCalendar->entityTreeModel();
0066 
0067     connect(&mCheckTimer, &QTimer::timeout, this, &KalendarAlarmClient::checkAlarms);
0068     connect(mETM, &Akonadi::EntityTreeModel::collectionPopulated, this, &KalendarAlarmClient::deferredInit);
0069     connect(mETM, &Akonadi::EntityTreeModel::collectionTreeFetched, this, &KalendarAlarmClient::deferredInit);
0070 
0071     checkAlarms();
0072 }
0073 
0074 void checkAllItems(KCheckableProxyModel *model, const QModelIndex &parent = QModelIndex())
0075 {
0076     const int rowCount = model->rowCount(parent);
0077     for (int row = 0; row < rowCount; ++row) {
0078         QModelIndex index = model->index(row, 0, parent);
0079         model->setData(index, Qt::Checked, Qt::CheckStateRole);
0080 
0081         if (model->rowCount(index) > 0) {
0082             checkAllItems(model, index);
0083         }
0084     }
0085 }
0086 
0087 void KalendarAlarmClient::deferredInit()
0088 {
0089     if (!collectionsAvailable()) {
0090         return;
0091     }
0092 
0093     qCDebug(REMINDER_DAEMON_LOG) << "Performing delayed initialization.";
0094 
0095     KCheckableProxyModel *checkableModel = mCalendar->checkableProxyModel();
0096     checkAllItems(checkableModel);
0097 
0098     // Now that everything is set up, a first check for reminders can be performed.
0099     checkAlarms();
0100 }
0101 
0102 void KalendarAlarmClient::restoreSuspendedFromConfig()
0103 {
0104     qCDebug(REMINDER_DAEMON_LOG) << "Restore suspended alarms from config";
0105     const KConfigGroup suspendedGroup(KSharedConfig::openConfig(), QLatin1StringView(mySuspensedGroupName));
0106     const auto suspendedAlarms = suspendedGroup.groupList();
0107 
0108     for (const auto &s : suspendedAlarms) {
0109         const KConfigGroup suspendedAlarm(&suspendedGroup, s);
0110         const QString uid = suspendedAlarm.readEntry("UID");
0111         const QString txt = suspendedAlarm.readEntry("Text");
0112         const QDateTime occurrence = suspendedAlarm.readEntry("Occurrence", QDateTime());
0113         const QDateTime remindAt = suspendedAlarm.readEntry("RemindAt", QDateTime());
0114         const bool wasSuspended = suspendedAlarm.readEntry("WasSuspensed", false);
0115         qCDebug(REMINDER_DAEMON_LOG) << "restoreSuspendedFromConfig: Restoring alarm" << uid << "," << txt << "," << remindAt;
0116 
0117         if (!uid.isEmpty() && remindAt.isValid()) {
0118             addNotification(uid, txt, occurrence, remindAt, wasSuspended);
0119         }
0120     }
0121 }
0122 
0123 void KalendarAlarmClient::dismiss(AlarmNotification *notification)
0124 {
0125     qCDebug(REMINDER_DAEMON_LOG) << "Alarm" << notification->uid() << "dismissed";
0126     removeNotification(notification);
0127     m_notifications.remove(notification->uid());
0128     delete notification;
0129 }
0130 
0131 void KalendarAlarmClient::suspend(AlarmNotification *notification, std::chrono::seconds sec)
0132 {
0133     qCDebug(REMINDER_DAEMON_LOG) << "Alarm " << notification->uid() << "suspended";
0134     notification->setRemindAt(QDateTime(QDateTime::currentDateTime()).addSecs(sec.count()));
0135     notification->setWasSuspended(true);
0136     storeNotification(notification);
0137 }
0138 
0139 void KalendarAlarmClient::showIncidence(const QString &uid, const QDateTime &occurrence, const QString &xdgActivationToken)
0140 {
0141     KConfig cfg(QStringLiteral("defaultcalendarrc"));
0142     KConfigGroup grp(&cfg, QStringLiteral("General"));
0143     const auto appId = grp.readEntry(QStringLiteral("ApplicationId"), QString());
0144     if (appId.isEmpty()) {
0145         return;
0146     }
0147     const auto kontactPlugin = grp.readEntry(QStringLiteral("KontactPlugin"), QStringLiteral("korganizer"));
0148 
0149     // start the calendar application if it isn't running yet
0150     const auto service = KService::serviceByDesktopName(appId);
0151     if (!service) {
0152         return;
0153     }
0154     auto job = new KIO::ApplicationLauncherJob(service, this);
0155     job->setStartupId(xdgActivationToken.toUtf8());
0156     connect(job, &KJob::finished, this, [appId, kontactPlugin, uid, occurrence, xdgActivationToken]() {
0157         // if running inside Kontact, select the right plugin
0158         if (appId == QLatin1StringView("org.kde.kontact")) {
0159             const QString objectName = QLatin1Char('/') + kontactPlugin + QLatin1StringView("_PimApplication");
0160             QDBusInterface iface(appId, objectName, QStringLiteral("org.kde.PIMUniqueApplication"), QDBusConnection::sessionBus());
0161             if (iface.isValid()) {
0162                 QStringList arguments({kontactPlugin});
0163                 iface.call(QStringLiteral("newInstance"), QByteArray(), arguments, QString());
0164             }
0165         }
0166 
0167         // select the right incidence/occurrence
0168         org::kde::calendar::Calendar iface(appId, QStringLiteral("/Calendar"), QDBusConnection::sessionBus());
0169         iface.showIncidenceByUid(uid, occurrence, xdgActivationToken);
0170     });
0171     job->start();
0172 }
0173 
0174 void KalendarAlarmClient::storeNotification(AlarmNotification *notification)
0175 {
0176     // Work around crashing when feeding a QString to the KConfigGroup constructor for the config group name
0177     // BUG: 456157
0178     const auto notificationUidUtf8 = notification->uid().toUtf8();
0179     const auto notificationUidData = notificationUidUtf8.constData();
0180 
0181     KConfigGroup suspendedGroup(KSharedConfig::openConfig(), QLatin1StringView(mySuspensedGroupName));
0182     KConfigGroup notificationGroup(&suspendedGroup, QLatin1StringView(notificationUidData));
0183     notificationGroup.writeEntry("UID", notificationUidData);
0184     notificationGroup.writeEntry("Text", notification->text());
0185     if (notification->occurrence().isValid()) {
0186         notificationGroup.writeEntry("Occurrence", notification->occurrence());
0187     }
0188     notificationGroup.writeEntry("RemindAt", notification->remindAt());
0189     notificationGroup.writeEntry("WasSuspensed", notification->wasSuspended());
0190     KSharedConfig::openConfig()->sync();
0191 }
0192 
0193 void KalendarAlarmClient::removeNotification(AlarmNotification *notification)
0194 {
0195     // Work around crashing when feeding a QString to the KConfigGroup constructor for the config group name
0196     // BUG: 456157
0197     const auto notificationUidUtf8 = notification->uid().toUtf8();
0198     const auto notificationUidData = notificationUidUtf8.constData();
0199 
0200     KConfigGroup suspendedGroup(KSharedConfig::openConfig(), QLatin1StringView(mySuspensedGroupName));
0201     KConfigGroup notificationGroup(&suspendedGroup, QLatin1StringView(notificationUidData));
0202     notificationGroup.deleteGroup();
0203     KSharedConfig::openConfig()->sync();
0204 }
0205 
0206 void KalendarAlarmClient::addNotification(const QString &uid, const QString &text, const QDateTime &occurrence, const QDateTime &remindTime, bool wasSuspended)
0207 {
0208     AlarmNotification *notification = nullptr;
0209     const auto it = m_notifications.constFind(uid);
0210     if (it != m_notifications.constEnd()) {
0211         notification = it.value();
0212     } else {
0213         notification = new AlarmNotification(uid);
0214     }
0215 
0216     if (notification->remindAt().isValid() && notification->remindAt() < remindTime) {
0217         // we have a notification for this event already, and it's scheduled earlier than the new one
0218         return;
0219     }
0220 
0221     // we either have no notification for this event yet, or one that is scheduled for later and that should be replaced
0222     qCDebug(REMINDER_DAEMON_LOG) << "Adding notification, uid:" << uid << "text:" << text << "remindTime:" << remindTime;
0223     notification->setText(text);
0224     notification->setOccurrence(occurrence);
0225     notification->setRemindAt(remindTime);
0226     notification->setWasSuspended(wasSuspended);
0227     m_notifications[notification->uid()] = notification;
0228     storeNotification(notification);
0229 }
0230 
0231 bool KalendarAlarmClient::collectionsAvailable() const
0232 {
0233     // The list of collections must be available.
0234     if (!mETM->isCollectionTreeFetched()) {
0235         return false;
0236     }
0237 
0238     // All collections must be populated.
0239     const int rowCount = mETM->rowCount();
0240     for (int row = 0; row < rowCount; ++row) {
0241         static const int column = 0;
0242         const QModelIndex index = mETM->index(row, column);
0243         const bool haveData = mETM->data(index, Akonadi::EntityTreeModel::IsPopulatedRole).toBool();
0244         if (!haveData) {
0245             return false;
0246         }
0247     }
0248 
0249     return true;
0250 }
0251 
0252 namespace
0253 {
0254 
0255 bool shouldNotify(const KCalendarCore::Incidence::Ptr &incidence, const Akonadi::CalendarSettings *settings)
0256 {
0257     if (settings->onlyShowRemindersForMyEvents()) {
0258         const auto isMe = [](const auto &person) -> bool {
0259             return KIdentityManagementCore::thatIsMe(person.email());
0260         };
0261 
0262         const auto attendees = incidence->attendees();
0263         if (std::any_of(attendees.cbegin(), attendees.cend(), isMe)) {
0264             return true;
0265         }
0266 
0267         if (isMe(incidence->organizer())) {
0268             return true;
0269         }
0270 
0271         return false;
0272     }
0273 
0274     return true;
0275 }
0276 
0277 } // namespace
0278 
0279 void KalendarAlarmClient::checkAlarms()
0280 {
0281     // We do not want to miss any reminders, so don't perform check unless
0282     // the collections are available and populated.
0283     if (!collectionsAvailable()) {
0284         qCDebug(REMINDER_DAEMON_LOG) << "Collections are not available; aborting check.";
0285         return;
0286     }
0287 
0288     const QDateTime from = mLastChecked.addSecs(1);
0289     mLastChecked = QDateTime::currentDateTime();
0290 
0291     qCDebug(REMINDER_DAEMON_LOG) << "Check:" << from.toString() << " -" << mLastChecked.toString();
0292 
0293     auto settings = Akonadi::CalendarSettings::self();
0294     settings->load(); // make sure we register changes to the config file
0295 
0296     // look for new alarms
0297     const Alarm::List alarms = mCalendar->alarms(from, mLastChecked, true /* exclude blocked alarms */);
0298     for (const Alarm::Ptr &alarm : alarms) {
0299         const QString uid = alarm->parentUid();
0300         const auto incidence = mCalendar->incidence(uid);
0301         if (incidence) {
0302             if (shouldNotify(incidence, settings)) {
0303                 const auto occurrence = occurrenceForAlarm(incidence, alarm, from);
0304                 addNotification(uid, alarm->text(), occurrence, mLastChecked, false);
0305             } else {
0306                 qCDebug(REMINDER_DAEMON_LOG) << "Alarm for incidence " << uid << "skipped, because we are not an organizer or attendee.";
0307             }
0308         } else {
0309             qCDebug(REMINDER_DAEMON_LOG) << "Alarm points" << alarm << "to an nonexisting incidence" << uid;
0310         }
0311     }
0312 
0313     QList<QString> nullAlarmNotificationIds;
0314     // We need a copy of the notifications hash as some may get dismissed as we iterate over them, causing a crash
0315     // BUG: 455902
0316     const auto notificationsCopy = m_notifications;
0317 
0318     // execute or update active alarms
0319     for (auto it = notificationsCopy.constBegin(); it != notificationsCopy.constEnd(); ++it) {
0320         const auto notification = it.value();
0321 
0322         // Protect against null ptr
0323         if (!notification) {
0324             qCDebug(REMINDER_DAEMON_LOG) << "Found null active alarm with id: " << it.key() << "Skipping.";
0325             nullAlarmNotificationIds.append(it.key());
0326             continue;
0327         }
0328 
0329         if (notification->remindAt() <= mLastChecked) {
0330             const auto incidence = mCalendar->incidence(notification->uid());
0331             if (incidence) { // can still be null when we get here during the early stages of loading/restoring
0332                 notification->send(this, incidence);
0333             }
0334         }
0335     }
0336 
0337     // Remove the null alarm notification ptrs from our notifications
0338     for (const auto &nullAlarmId : nullAlarmNotificationIds) {
0339         m_notifications.remove(nullAlarmId);
0340     }
0341 
0342     saveLastCheckTime();
0343 
0344     // schedule next check for the beginning of the next minute
0345     mCheckTimer.start(std::chrono::seconds(60 - mLastChecked.time().second()));
0346 }
0347 
0348 void KalendarAlarmClient::saveLastCheckTime()
0349 {
0350     KConfigGroup cg(KSharedConfig::openConfig(), QStringLiteral("Alarms"));
0351     cg.writeEntry("CalendarsLastChecked", mLastChecked);
0352     KSharedConfig::openConfig()->sync();
0353 }
0354 
0355 // based on KCalendarCore::Calendar::appendRecurringAlarms()
0356 QDateTime
0357 KalendarAlarmClient::occurrenceForAlarm(const KCalendarCore::Incidence::Ptr &incidence, const KCalendarCore::Alarm::Ptr &alarm, const QDateTime &from) const
0358 {
0359     // recurring alarms not handled here for simplicity
0360     if (incidence.isNull() || !incidence->recurs() || alarm->repeatCount()) {
0361         return {};
0362     }
0363 
0364     // Alarm time is defined by an offset from the event start or end time.
0365     // Find the offset from the event start time, which is also used as the
0366     // offset from the recurrence time.
0367     Duration offset(0), endOffset(0);
0368     if (alarm->hasStartOffset()) {
0369         offset = alarm->startOffset();
0370     } else if (alarm->hasEndOffset()) {
0371         offset = alarm->endOffset();
0372         endOffset = Duration(incidence->dtStart(), incidence->dateTime(Incidence::RoleAlarmEndOffset));
0373     } else {
0374         // alarms at a fixed time, not handled here for simplicity
0375         return {};
0376     }
0377 
0378     // Find the incidence's earliest alarm
0379     QDateTime alarmStart = offset.end(alarm->hasEndOffset() ? incidence->dateTime(Incidence::RoleAlarmEndOffset) : incidence->dtStart());
0380     QDateTime baseStart = incidence->dtStart();
0381     if (from > alarmStart) {
0382         alarmStart = from; // don't look earlier than the earliest alarm
0383         baseStart = (-offset).end((-endOffset).end(alarmStart));
0384     }
0385 
0386     // Find the next occurrence from the earliest possible alarm time
0387     return incidence->recurrence()->getNextDateTime(baseStart.addSecs(-1));
0388 }
0389 
0390 #include "moc_kalendaralarmclient.cpp"