1/*
2 This file is part of the KDE libraries
3 SPDX-FileCopyrightText: 2014-2015 Martin Klapetek <mklapetek@kde.org>
4 SPDX-FileCopyrightText: 2018 Kai Uwe Broulik <kde@privat.broulik.de>
5 SPDX-FileCopyrightText: 2023 Ismael Asensio <isma.af@gmail.com>
6
7 SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
8*/
9
10#include "notifybyaudio.h"
11#include "debug_p.h"
12
13#include <QFile>
14#include <QFileInfo>
15#include <QGuiApplication>
16#include <QIcon>
17#include <QString>
18
19#include "knotification.h"
20#include "knotifyconfig.h"
21
22#include <canberra.h>
23
24const QString DEFAULT_SOUND_THEME = QStringLiteral("ocean");
25
26NotifyByAudio::NotifyByAudio(QObject *parent)
27 : KNotificationPlugin(parent)
28 , m_soundTheme(DEFAULT_SOUND_THEME)
29 , m_enabled(true)
30{
31 qRegisterMetaType<uint32_t>(typeName: "uint32_t");
32
33 m_settingsWatcher = KConfigWatcher::create(config: KSharedConfig::openConfig(QStringLiteral("kdeglobals")));
34 connect(sender: m_settingsWatcher.get(), signal: &KConfigWatcher::configChanged, context: this, slot: [this](const KConfigGroup &group, const QByteArrayList &names) {
35 if (group.name() != QLatin1String("Sounds")) {
36 return;
37 }
38 if (names.contains(QByteArrayLiteral("Theme"))) {
39 m_soundTheme = group.readEntry(key: "Theme", aDefault: DEFAULT_SOUND_THEME);
40 }
41 if (names.contains(QByteArrayLiteral("Enable"))) {
42 m_enabled = group.readEntry(key: "Enable", defaultValue: true);
43 }
44 });
45
46 const KConfigGroup group = m_settingsWatcher->config()->group(QStringLiteral("Sounds"));
47 m_soundTheme = group.readEntry(key: "Theme", aDefault: DEFAULT_SOUND_THEME);
48 m_enabled = group.readEntry(key: "Enable", defaultValue: true);
49}
50
51NotifyByAudio::~NotifyByAudio()
52{
53 if (m_context) {
54 ca_context_destroy(c: m_context);
55 }
56 m_context = nullptr;
57}
58
59ca_context *NotifyByAudio::context()
60{
61 if (m_context) {
62 return m_context;
63 }
64
65 int ret = ca_context_create(c: &m_context);
66 if (ret != CA_SUCCESS) {
67 qCWarning(LOG_KNOTIFICATIONS) << "Failed to initialize canberra context for audio notification:" << ca_strerror(code: ret);
68 m_context = nullptr;
69 return nullptr;
70 }
71
72 QString desktopFileName = QGuiApplication::desktopFileName();
73 // handle apps which set the desktopFileName property with filename suffix,
74 // due to unclear API dox (https://bugreports.qt.io/browse/QTBUG-75521)
75 if (desktopFileName.endsWith(s: QLatin1String(".desktop"))) {
76 desktopFileName.chop(n: 8);
77 }
78 ret = ca_context_change_props(c: m_context,
79 CA_PROP_APPLICATION_NAME,
80 qUtf8Printable(qApp->applicationDisplayName()),
81 CA_PROP_APPLICATION_ID,
82 qUtf8Printable(desktopFileName),
83 CA_PROP_APPLICATION_ICON_NAME,
84 qUtf8Printable(qApp->windowIcon().name()),
85 nullptr);
86 if (ret != CA_SUCCESS) {
87 qCWarning(LOG_KNOTIFICATIONS) << "Failed to set application properties on canberra context for audio notification:" << ca_strerror(code: ret);
88 }
89
90 return m_context;
91}
92
93void NotifyByAudio::notify(KNotification *notification, const KNotifyConfig &notifyConfig)
94{
95 if (!m_enabled) {
96 qCDebug(LOG_KNOTIFICATIONS) << "Notification sounds are globally disabled";
97 return;
98 }
99
100 const QString soundName = notifyConfig.readEntry(QStringLiteral("Sound"));
101 if (soundName.isEmpty()) {
102 qCWarning(LOG_KNOTIFICATIONS) << "Audio notification requested, but no sound name provided in notifyrc file, aborting audio notification";
103
104 finish(notification);
105 return;
106 }
107
108 // Legacy implementation. Fallback lookup for a full path within the `$XDG_DATA_LOCATION/sounds` dirs
109 QUrl fallbackUrl;
110 const auto dataLocations = QStandardPaths::standardLocations(type: QStandardPaths::GenericDataLocation);
111 for (const QString &dataLocation : dataLocations) {
112 fallbackUrl = QUrl::fromUserInput(userInput: soundName, workingDirectory: dataLocation + QStringLiteral("/sounds"), options: QUrl::AssumeLocalFile);
113 if (fallbackUrl.isLocalFile() && QFileInfo::exists(file: fallbackUrl.toLocalFile())) {
114 break;
115 } else if (!fallbackUrl.isLocalFile() && fallbackUrl.isValid()) {
116 break;
117 }
118 fallbackUrl.clear();
119 }
120
121 // Looping happens in the finishCallback
122 if (!playSound(id: m_currentId, eventName: soundName, fallbackUrl)) {
123 finish(notification);
124 return;
125 }
126
127 if (notification->flags() & KNotification::LoopSound) {
128 m_loopSoundUrls.insert(key: m_currentId, value: {soundName, fallbackUrl});
129 }
130
131 Q_ASSERT(!m_notifications.value(m_currentId));
132 m_notifications.insert(key: m_currentId, value: notification);
133
134 ++m_currentId;
135}
136
137bool NotifyByAudio::playSound(quint32 id, const QString &soundName, const QUrl &fallbackUrl)
138{
139 if (!context()) {
140 qCWarning(LOG_KNOTIFICATIONS) << "Cannot play notification sound without canberra context";
141 return false;
142 }
143
144 ca_proplist *props = nullptr;
145 ca_proplist_create(p: &props);
146
147 ca_proplist_sets(p: props, CA_PROP_EVENT_ID, value: soundName.toLatin1().constData());
148 ca_proplist_sets(p: props, CA_PROP_CANBERRA_XDG_THEME_NAME, value: m_soundTheme.toLatin1().constData());
149 // Fallback to filename
150 if (!fallbackUrl.isEmpty()) {
151 ca_proplist_sets(p: props, CA_PROP_MEDIA_FILENAME, value: QFile::encodeName(fileName: fallbackUrl.toLocalFile()).constData());
152 }
153 // We'll also want this cached for a time. volatile makes sure the cache is
154 // dropped after some time or when the cache is under pressure.
155 ca_proplist_sets(p: props, CA_PROP_CANBERRA_CACHE_CONTROL, value: "volatile");
156
157 int ret = ca_context_play_full(c: context(), id, p: props, cb: &ca_finish_callback, userdata: this);
158
159 ca_proplist_destroy(p: props);
160
161 if (ret != CA_SUCCESS) {
162 qCWarning(LOG_KNOTIFICATIONS) << "Failed to play sound with canberra:" << ca_strerror(code: ret);
163 return false;
164 }
165
166 return true;
167}
168
169void NotifyByAudio::ca_finish_callback(ca_context *c, uint32_t id, int error_code, void *userdata)
170{
171 Q_UNUSED(c);
172 QMetaObject::invokeMethod(obj: static_cast<NotifyByAudio *>(userdata), member: "finishCallback", Q_ARG(uint32_t, id), Q_ARG(int, error_code));
173}
174
175void NotifyByAudio::finishCallback(uint32_t id, int error_code)
176{
177 KNotification *notification = m_notifications.value(key: id, defaultValue: nullptr);
178 if (!notification) {
179 // We may have gotten a late finish callback.
180 return;
181 }
182
183 if (error_code == CA_SUCCESS) {
184 // Loop the sound now if we have one
185 auto soundInfoIt = m_loopSoundUrls.constFind(key: id);
186 if (soundInfoIt != m_loopSoundUrls.constEnd()) {
187 if (!playSound(id, soundName: soundInfoIt->first, fallbackUrl: soundInfoIt->second)) {
188 finishNotification(notification, id);
189 }
190 return;
191 }
192 } else if (error_code != CA_ERROR_CANCELED) {
193 qCWarning(LOG_KNOTIFICATIONS) << "Playing audio notification failed:" << ca_strerror(code: error_code);
194 }
195
196 finishNotification(notification, id);
197}
198
199void NotifyByAudio::close(KNotification *notification)
200{
201 if (!m_notifications.values().contains(t: notification)) {
202 return;
203 }
204
205 const auto id = m_notifications.key(value: notification);
206 if (m_context) {
207 int ret = ca_context_cancel(c: m_context, id);
208 if (ret != CA_SUCCESS) {
209 qCWarning(LOG_KNOTIFICATIONS) << "Failed to cancel canberra context for audio notification:" << ca_strerror(code: ret);
210 return;
211 }
212 }
213
214 // Consider the notification finished. ca_context_cancel schedules a cancel
215 // but we need to stop using the noficiation immediately or we could access
216 // a notification past its lifetime (close() may, or indeed must,
217 // schedule deletion of the notification).
218 // https://bugs.kde.org/show_bug.cgi?id=398695
219 finishNotification(notification, id);
220}
221
222void NotifyByAudio::finishNotification(KNotification *notification, quint32 id)
223{
224 m_notifications.remove(key: id);
225 m_loopSoundUrls.remove(key: id);
226 finish(notification);
227}
228
229#include "moc_notifybyaudio.cpp"
230

source code of knotifications/src/notifybyaudio.cpp