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"