File indexing completed on 2024-05-05 03:57:00

0001 /*
0002     This file is part of the KDE libraries
0003     SPDX-FileCopyrightText: 2014-2015 Martin Klapetek <mklapetek@kde.org>
0004     SPDX-FileCopyrightText: 2018 Kai Uwe Broulik <kde@privat.broulik.de>
0005     SPDX-FileCopyrightText: 2023 Ismael Asensio <isma.af@gmail.com>
0006 
0007     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0008 */
0009 
0010 #include "notifybyaudio.h"
0011 #include "debug_p.h"
0012 
0013 #include <QFile>
0014 #include <QFileInfo>
0015 #include <QGuiApplication>
0016 #include <QIcon>
0017 #include <QString>
0018 
0019 #include "knotification.h"
0020 #include "knotifyconfig.h"
0021 
0022 #include <canberra.h>
0023 
0024 const QString DEFAULT_SOUND_THEME = QStringLiteral("ocean");
0025 
0026 NotifyByAudio::NotifyByAudio(QObject *parent)
0027     : KNotificationPlugin(parent)
0028     , m_soundTheme(DEFAULT_SOUND_THEME)
0029     , m_enabled(true)
0030 {
0031     qRegisterMetaType<uint32_t>("uint32_t");
0032 
0033     m_settingsWatcher = KConfigWatcher::create(KSharedConfig::openConfig(QStringLiteral("kdeglobals")));
0034     connect(m_settingsWatcher.get(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group, const QByteArrayList &names) {
0035         if (group.name() != QLatin1String("Sounds")) {
0036             return;
0037         }
0038         if (names.contains(QByteArrayLiteral("Theme"))) {
0039             m_soundTheme = group.readEntry("Theme", DEFAULT_SOUND_THEME);
0040         }
0041         if (names.contains(QByteArrayLiteral("Enable"))) {
0042             m_enabled = group.readEntry("Enable", true);
0043         }
0044     });
0045 
0046     const KConfigGroup group = m_settingsWatcher->config()->group(QStringLiteral("Sounds"));
0047     m_soundTheme = group.readEntry("Theme", DEFAULT_SOUND_THEME);
0048     m_enabled = group.readEntry("Enable", true);
0049 }
0050 
0051 NotifyByAudio::~NotifyByAudio()
0052 {
0053     if (m_context) {
0054         ca_context_destroy(m_context);
0055     }
0056     m_context = nullptr;
0057 }
0058 
0059 ca_context *NotifyByAudio::context()
0060 {
0061     if (m_context) {
0062         return m_context;
0063     }
0064 
0065     int ret = ca_context_create(&m_context);
0066     if (ret != CA_SUCCESS) {
0067         qCWarning(LOG_KNOTIFICATIONS) << "Failed to initialize canberra context for audio notification:" << ca_strerror(ret);
0068         m_context = nullptr;
0069         return nullptr;
0070     }
0071 
0072     QString desktopFileName = QGuiApplication::desktopFileName();
0073     // handle apps which set the desktopFileName property with filename suffix,
0074     // due to unclear API dox (https://bugreports.qt.io/browse/QTBUG-75521)
0075     if (desktopFileName.endsWith(QLatin1String(".desktop"))) {
0076         desktopFileName.chop(8);
0077     }
0078     ret = ca_context_change_props(m_context,
0079                                   CA_PROP_APPLICATION_NAME,
0080                                   qUtf8Printable(qApp->applicationDisplayName()),
0081                                   CA_PROP_APPLICATION_ID,
0082                                   qUtf8Printable(desktopFileName),
0083                                   CA_PROP_APPLICATION_ICON_NAME,
0084                                   qUtf8Printable(qApp->windowIcon().name()),
0085                                   nullptr);
0086     if (ret != CA_SUCCESS) {
0087         qCWarning(LOG_KNOTIFICATIONS) << "Failed to set application properties on canberra context for audio notification:" << ca_strerror(ret);
0088     }
0089 
0090     return m_context;
0091 }
0092 
0093 void NotifyByAudio::notify(KNotification *notification, const KNotifyConfig &notifyConfig)
0094 {
0095     if (!m_enabled) {
0096         qCDebug(LOG_KNOTIFICATIONS) << "Notification sounds are globally disabled";
0097         return;
0098     }
0099 
0100     const QString soundName = notifyConfig.readEntry(QStringLiteral("Sound"));
0101     if (soundName.isEmpty()) {
0102         qCWarning(LOG_KNOTIFICATIONS) << "Audio notification requested, but no sound name provided in notifyrc file, aborting audio notification";
0103 
0104         finish(notification);
0105         return;
0106     }
0107 
0108     // Legacy implementation. Fallback lookup for a full path within the `$XDG_DATA_LOCATION/sounds` dirs
0109     QUrl fallbackUrl;
0110     const auto dataLocations = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation);
0111     for (const QString &dataLocation : dataLocations) {
0112         fallbackUrl = QUrl::fromUserInput(soundName, dataLocation + QStringLiteral("/sounds"), QUrl::AssumeLocalFile);
0113         if (fallbackUrl.isLocalFile() && QFileInfo::exists(fallbackUrl.toLocalFile())) {
0114             break;
0115         } else if (!fallbackUrl.isLocalFile() && fallbackUrl.isValid()) {
0116             break;
0117         }
0118         fallbackUrl.clear();
0119     }
0120 
0121     // Looping happens in the finishCallback
0122     if (!playSound(m_currentId, soundName, fallbackUrl)) {
0123         finish(notification);
0124         return;
0125     }
0126 
0127     if (notification->flags() & KNotification::LoopSound) {
0128         m_loopSoundUrls.insert(m_currentId, {soundName, fallbackUrl});
0129     }
0130 
0131     Q_ASSERT(!m_notifications.value(m_currentId));
0132     m_notifications.insert(m_currentId, notification);
0133 
0134     ++m_currentId;
0135 }
0136 
0137 bool NotifyByAudio::playSound(quint32 id, const QString &soundName, const QUrl &fallbackUrl)
0138 {
0139     if (!context()) {
0140         qCWarning(LOG_KNOTIFICATIONS) << "Cannot play notification sound without canberra context";
0141         return false;
0142     }
0143 
0144     ca_proplist *props = nullptr;
0145     ca_proplist_create(&props);
0146 
0147     ca_proplist_sets(props, CA_PROP_EVENT_ID, soundName.toLatin1().constData());
0148     ca_proplist_sets(props, CA_PROP_CANBERRA_XDG_THEME_NAME, m_soundTheme.toLatin1().constData());
0149     // Fallback to filename
0150     if (!fallbackUrl.isEmpty()) {
0151         ca_proplist_sets(props, CA_PROP_MEDIA_FILENAME, QFile::encodeName(fallbackUrl.toLocalFile()).constData());
0152     }
0153     // We'll also want this cached for a time. volatile makes sure the cache is
0154     // dropped after some time or when the cache is under pressure.
0155     ca_proplist_sets(props, CA_PROP_CANBERRA_CACHE_CONTROL, "volatile");
0156 
0157     int ret = ca_context_play_full(context(), id, props, &ca_finish_callback, this);
0158 
0159     ca_proplist_destroy(props);
0160 
0161     if (ret != CA_SUCCESS) {
0162         qCWarning(LOG_KNOTIFICATIONS) << "Failed to play sound with canberra:" << ca_strerror(ret);
0163         return false;
0164     }
0165 
0166     return true;
0167 }
0168 
0169 void NotifyByAudio::ca_finish_callback(ca_context *c, uint32_t id, int error_code, void *userdata)
0170 {
0171     Q_UNUSED(c);
0172     QMetaObject::invokeMethod(static_cast<NotifyByAudio *>(userdata), "finishCallback", Q_ARG(uint32_t, id), Q_ARG(int, error_code));
0173 }
0174 
0175 void NotifyByAudio::finishCallback(uint32_t id, int error_code)
0176 {
0177     KNotification *notification = m_notifications.value(id, nullptr);
0178     if (!notification) {
0179         // We may have gotten a late finish callback.
0180         return;
0181     }
0182 
0183     if (error_code == CA_SUCCESS) {
0184         // Loop the sound now if we have one
0185         auto soundInfoIt = m_loopSoundUrls.constFind(id);
0186         if (soundInfoIt != m_loopSoundUrls.constEnd()) {
0187             if (!playSound(id, soundInfoIt->first, soundInfoIt->second)) {
0188                 finishNotification(notification, id);
0189             }
0190             return;
0191         }
0192     } else if (error_code != CA_ERROR_CANCELED) {
0193         qCWarning(LOG_KNOTIFICATIONS) << "Playing audio notification failed:" << ca_strerror(error_code);
0194     }
0195 
0196     finishNotification(notification, id);
0197 }
0198 
0199 void NotifyByAudio::close(KNotification *notification)
0200 {
0201     if (!m_notifications.values().contains(notification)) {
0202         return;
0203     }
0204 
0205     const auto id = m_notifications.key(notification);
0206     if (m_context) {
0207         int ret = ca_context_cancel(m_context, id);
0208         if (ret != CA_SUCCESS) {
0209             qCWarning(LOG_KNOTIFICATIONS) << "Failed to cancel canberra context for audio notification:" << ca_strerror(ret);
0210             return;
0211         }
0212     }
0213 
0214     // Consider the notification finished. ca_context_cancel schedules a cancel
0215     // but we need to stop using the noficiation immediately or we could access
0216     // a notification past its lifetime (close() may, or indeed must,
0217     // schedule deletion of the notification).
0218     // https://bugs.kde.org/show_bug.cgi?id=398695
0219     finishNotification(notification, id);
0220 }
0221 
0222 void NotifyByAudio::finishNotification(KNotification *notification, quint32 id)
0223 {
0224     m_notifications.remove(id);
0225     m_loopSoundUrls.remove(id);
0226     finish(notification);
0227 }
0228 
0229 #include "moc_notifybyaudio.cpp"