From a5243a0d894dee1371b18217689c6c53fdae48fc Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Tue, 2 Jun 2026 14:28:36 +0200 Subject: [PATCH] feat(audio): replace Qt Multimedia with a native audio abstraction The Qt Multimedia framework was pulled into the desktop client solely to play the call-notification ringtone in a loop via QML's SoundEffect. This introduces NotificationSoundPlayer, a thin in-tree C++ class that wraps the OS-native audio APIs (AVAudioPlayer on macOS, XAudio2 on Windows, libcanberra on Linux) and exposes the same minimal QML API the dialog needs. Removing the import lets macdeployqt drop the QtMultimedia framework and QML plugin from the bundle and unblocks shrinking the Windows installer. The dispatcher resolves qrc:/file:/plain-path sources, extracts QRC contents to the cache with an atomic-rename + QTemporaryFile fallback, and drives loop counting for backends that play one buffer at a time (Linux). Native-loop backends (macOS, Windows) play the whole sequence in one shot. Backend choice is platform-gated in CMake; libcanberra is a soft build dependency on Linux with a no-op fallback. Also deletes two dead Qt5Multimedia.dll references from the NSIS template that could never resolve in the current Qt6 build. Closes #9886 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Iva Horn --- cmake/modules/NSIS.template.in | 2 - src/gui/CMakeLists.txt | 30 ++- src/gui/notificationsoundplayer.cpp | 293 ++++++++++++++++++++++ src/gui/notificationsoundplayer.h | 62 +++++ src/gui/notificationsoundplayer_linux.cpp | 161 ++++++++++++ src/gui/notificationsoundplayer_mac.mm | 137 ++++++++++ src/gui/notificationsoundplayer_p.h | 48 ++++ src/gui/notificationsoundplayer_win.cpp | 281 +++++++++++++++++++++ src/gui/owncloudgui.cpp | 2 + src/gui/tray/CallNotificationDialog.qml | 3 +- test/CMakeLists.txt | 1 + test/testnotificationsoundplayer.cpp | 251 ++++++++++++++++++ 12 files changed, 1266 insertions(+), 5 deletions(-) create mode 100644 src/gui/notificationsoundplayer.cpp create mode 100644 src/gui/notificationsoundplayer.h create mode 100644 src/gui/notificationsoundplayer_linux.cpp create mode 100644 src/gui/notificationsoundplayer_mac.mm create mode 100644 src/gui/notificationsoundplayer_p.h create mode 100644 src/gui/notificationsoundplayer_win.cpp create mode 100644 test/testnotificationsoundplayer.cpp diff --git a/cmake/modules/NSIS.template.in b/cmake/modules/NSIS.template.in index 729d011872def..ee499de2910da 100644 --- a/cmake/modules/NSIS.template.in +++ b/cmake/modules/NSIS.template.in @@ -433,8 +433,6 @@ Section "${APPLICATION_NAME}" SEC_APPLICATION File "${QT_DLL_PATH}\Qt5Xml.dll" ;QtWebKit dependencies - File "${QT_DLL_PATH}\Qt5Multimedia.dll" - File "${QT_DLL_PATH}\Qt5MultimediaWidgets.dll" File "${QT_DLL_PATH}\Qt5Sensors.dll" ;Qt deps diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 97a7fc7783acd..b5466e38e9f23 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -72,6 +72,9 @@ set(client_SRCS caseclashfilenamedialog.cpp callstatechecker.h callstatechecker.cpp + notificationsoundplayer.h + notificationsoundplayer.cpp + notificationsoundplayer_p.h conflictdialog.h conflictdialog.cpp conflictsolver.h @@ -289,6 +292,7 @@ endif() IF( APPLE ) list(APPEND client_SRCS cocoainitializer_mac.mm) list(APPEND client_SRCS systray_mac_common.mm) + list(APPEND client_SRCS notificationsoundplayer_mac.mm) list(APPEND client_SRCS macOS/trayaccountpopup_mac.mm) list(APPEND client_SRCS macOS/singleinstancemanager_mac.mm) @@ -351,12 +355,13 @@ IF( APPLE ) ENDIF() IF( NOT WIN32 AND NOT APPLE ) - set(client_SRCS ${client_SRCS} folderwatcher_linux.cpp) + set(client_SRCS ${client_SRCS} folderwatcher_linux.cpp notificationsoundplayer_linux.cpp) ENDIF() IF( WIN32 ) set(client_SRCS ${client_SRCS} folderwatcher_win.cpp + notificationsoundplayer_win.cpp navigationpanehelper.h navigationpanehelper.cpp shellextensionsserver.cpp @@ -677,6 +682,22 @@ if( UNIX AND NOT APPLE ) find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} COMPONENTS DBus) target_link_libraries(nextcloudCore PUBLIC Qt::DBus) target_compile_definitions(nextcloudCore PUBLIC "USE_FDO_NOTIFICATIONS") + + # libcanberra is the freedesktop event-sound API used to play the + # incoming-call ringtone. If the dev package is missing, we fall back to + # a no-op backend so the build still succeeds; distro packaging should + # treat libcanberra as a soft runtime dependency. + find_package(PkgConfig) + if(PkgConfig_FOUND) + pkg_check_modules(CANBERRA IMPORTED_TARGET libcanberra) + endif() + if(CANBERRA_FOUND) + target_link_libraries(nextcloudCore PRIVATE PkgConfig::CANBERRA) + target_compile_definitions(nextcloudCore PRIVATE HAVE_LIBCANBERRA) + else() + message(STATUS "libcanberra not found; notification sounds will be silent on this build") + endif() + target_compile_definitions(nextcloudCore PRIVATE NEXTCLOUD_HAS_NATIVE_SOUND_BACKEND) endif() if (APPLE) @@ -685,6 +706,13 @@ if (APPLE) else() target_link_libraries(nextcloudCore PUBLIC "-framework UserNotifications") endif() + target_link_libraries(nextcloudCore PRIVATE "-framework AVFoundation" "-framework Foundation") + target_compile_definitions(nextcloudCore PRIVATE NEXTCLOUD_HAS_NATIVE_SOUND_BACKEND) +endif() + +if (WIN32) + target_link_libraries(nextcloudCore PRIVATE xaudio2) + target_compile_definitions(nextcloudCore PRIVATE NEXTCLOUD_HAS_NATIVE_SOUND_BACKEND) endif() install(TARGETS nextcloud diff --git a/src/gui/notificationsoundplayer.cpp b/src/gui/notificationsoundplayer.cpp new file mode 100644 index 0000000000000..8bd4666df4ae3 --- /dev/null +++ b/src/gui/notificationsoundplayer.cpp @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "notificationsoundplayer.h" +#include "notificationsoundplayer_p.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcNotificationSoundPlayer, "nextcloud.gui.notificationsoundplayer", QtInfoMsg) + +namespace { + +QString extractQrcToCache(const QString &qrcResourcePath) +{ + static QMutex cacheMutex; + static QHash extractedPaths; + + QMutexLocker locker(&cacheMutex); + if (const auto cached = extractedPaths.value(qrcResourcePath); !cached.isEmpty()) { + if (QFile::exists(cached)) { + return cached; + } + extractedPaths.remove(qrcResourcePath); + } + + QFile source(qrcResourcePath); + if (!source.open(QIODevice::ReadOnly)) { + qCWarning(lcNotificationSoundPlayer) << "Cannot open embedded resource" << qrcResourcePath + << ":" << source.errorString(); + return {}; + } + const auto bytes = source.readAll(); + source.close(); + + QByteArray fingerprint = QByteArrayLiteral("notificationsoundplayer:"); + fingerprint += qrcResourcePath.toUtf8(); + fingerprint += ':'; + fingerprint += QByteArray::number(bytes.size()); + const auto hash = QCryptographicHash::hash(fingerprint, QCryptographicHash::Sha1).toHex(); + + const auto cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/sounds"); + const auto suffix = QFileInfo(qrcResourcePath).suffix(); + const auto dottedSuffix = suffix.isEmpty() ? QString() : QStringLiteral(".") + suffix; + const auto destinationPath = cacheDir + QStringLiteral("/") + QString::fromLatin1(hash) + dottedSuffix; + + if (QFile::exists(destinationPath)) { + extractedPaths.insert(qrcResourcePath, destinationPath); + return destinationPath; + } + + if (QDir().mkpath(cacheDir)) { + QSaveFile out(destinationPath); + if (out.open(QIODevice::WriteOnly)) { + if (out.write(bytes) == bytes.size() && out.commit()) { + extractedPaths.insert(qrcResourcePath, destinationPath); + return destinationPath; + } + qCWarning(lcNotificationSoundPlayer) << "Failed to write extracted sound to" << destinationPath + << ":" << out.errorString(); + } else { + qCWarning(lcNotificationSoundPlayer) << "Failed to open" << destinationPath << "for writing:" + << out.errorString(); + } + } else { + qCWarning(lcNotificationSoundPlayer) << "Cannot create cache directory" << cacheDir + << "for extracted sounds; falling back to QTemporaryFile"; + } + + QTemporaryFile fallback(QDir::tempPath() + QStringLiteral("/nc-sound-XXXXXX") + dottedSuffix); + fallback.setAutoRemove(false); + if (fallback.open() && fallback.write(bytes) == bytes.size()) { + fallback.close(); + const auto fallbackPath = fallback.fileName(); + extractedPaths.insert(qrcResourcePath, fallbackPath); + return fallbackPath; + } + + qCWarning(lcNotificationSoundPlayer) << "Could not materialise embedded sound" << qrcResourcePath + << "to a filesystem path"; + return {}; +} + +} + +#if !defined(NEXTCLOUD_HAS_NATIVE_SOUND_BACKEND) +namespace { + +class NoOpBackend : public NotificationSoundPlayer::Backend +{ +public: + void setSource(const QString &) override {} + + void play(int) override + { + if (!_warned) { + qCWarning(lcNotificationSoundPlayer) << "No native audio backend compiled in; notification sound is silent"; + _warned = true; + } + if (onFinished) { + // Invoke asynchronously so loop bookkeeping does not recurse + // inside the dispatcher's play() call. + auto cb = onFinished; + QMetaObject::invokeMethod(qApp, [cb]() { cb(); }, Qt::QueuedConnection); + } + } + + void stop() override {} + + [[nodiscard]] bool handlesLoopsNatively() const override { return true; } + +private: + bool _warned = false; +}; + +} + +std::unique_ptr createNotificationSoundPlayerBackend() +{ + return std::make_unique(); +} +#endif + +NotificationSoundPlayer::NotificationSoundPlayer(QObject *parent) + : QObject(parent) + , _backend(createNotificationSoundPlayerBackend()) +{ + bindBackend(); +} + +NotificationSoundPlayer::NotificationSoundPlayer(std::unique_ptr backend, QObject *parent) + : QObject(parent) + , _backend(std::move(backend)) +{ + bindBackend(); +} + +NotificationSoundPlayer::~NotificationSoundPlayer() +{ + if (_backend) { + _backend->stop(); + } +} + +void NotificationSoundPlayer::bindBackend() +{ + _backend->onFinished = [this]() { + // Backend may invoke this from a worker thread. Marshal to the + // dispatcher's thread via the lambda-on-QObject overload of + // invokeMethod, which silently drops the event if `this` is + // destroyed before delivery. + QMetaObject::invokeMethod(this, [this]() { onBackendPlaybackFinished(); }, Qt::QueuedConnection); + }; +} + +QString NotificationSoundPlayer::source() const +{ + return _source; +} + +int NotificationSoundPlayer::loops() const +{ + return _loops; +} + +bool NotificationSoundPlayer::isPlaying() const +{ + return _playing; +} + +void NotificationSoundPlayer::setSource(const QString &source) +{ + if (_source == source) { + return; + } + _source = source; + _resolvedFilePath.clear(); + Q_EMIT sourceChanged(); +} + +void NotificationSoundPlayer::setLoops(int loops) +{ + if (_loops == loops) { + return; + } + _loops = loops; + Q_EMIT loopsChanged(); +} + +void NotificationSoundPlayer::play() +{ + if (_source.isEmpty() || _loops <= 0) { + return; + } + + if (_resolvedFilePath.isEmpty()) { + _resolvedFilePath = resolveToFilesystemPath(_source); + if (_resolvedFilePath.isEmpty()) { + return; + } + _backend->setSource(_resolvedFilePath); + } + + if (_playing) { + _backend->stop(); + } + + _remainingLoops = _loops; + setPlaying(true); + _backend->play(_loops); +} + +void NotificationSoundPlayer::stop() +{ + if (!_playing) { + return; + } + _remainingLoops = 0; + _backend->stop(); + setPlaying(false); +} + +void NotificationSoundPlayer::onBackendPlaybackFinished() +{ + if (!_playing) { + return; + } + + if (!_backend->handlesLoopsNatively() && --_remainingLoops > 0) { + _backend->play(_remainingLoops); + return; + } + + _remainingLoops = 0; + setPlaying(false); +} + +void NotificationSoundPlayer::setPlaying(bool playing) +{ + if (_playing == playing) { + return; + } + _playing = playing; + Q_EMIT playingChanged(); +} + +QString NotificationSoundPlayer::resolveToFilesystemPath(const QString &source) +{ + if (source.isEmpty()) { + return {}; + } + + QString candidate = source; + if (source.startsWith(QStringLiteral("qrc://"))) { + candidate = QStringLiteral(":") + source.mid(6); + } else if (source.startsWith(QStringLiteral("qrc:"))) { + candidate = QStringLiteral(":") + source.mid(4); + } else if (source.startsWith(QStringLiteral("file://"))) { + candidate = QUrl(source).toLocalFile(); + } + + if (candidate.startsWith(QStringLiteral(":"))) { + const auto extracted = extractQrcToCache(candidate); + if (extracted.isEmpty()) { + return {}; + } + return extracted; + } + + if (!QFile::exists(candidate)) { + qCWarning(lcNotificationSoundPlayer) << "Sound source does not exist:" << source; + return {}; + } + return candidate; +} + +} diff --git a/src/gui/notificationsoundplayer.h b/src/gui/notificationsoundplayer.h new file mode 100644 index 0000000000000..0a83cc0a0a0db --- /dev/null +++ b/src/gui/notificationsoundplayer.h @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include + +namespace OCC { + +class NotificationSoundPlayer : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString source READ source WRITE setSource NOTIFY sourceChanged) + Q_PROPERTY(int loops READ loops WRITE setLoops NOTIFY loopsChanged) + Q_PROPERTY(bool playing READ isPlaying NOTIFY playingChanged) + +public: + class Backend; + + explicit NotificationSoundPlayer(QObject *parent = nullptr); + NotificationSoundPlayer(std::unique_ptr backend, QObject *parent); + ~NotificationSoundPlayer() override; + + [[nodiscard]] QString source() const; + [[nodiscard]] int loops() const; + [[nodiscard]] bool isPlaying() const; + + // Translate a `qrc:`/`file:`/plain path into a real filesystem path, + // materialising QRC-embedded sounds into the cache on first use. + // Public so unit tests can exercise the cache + fallback paths. + static QString resolveToFilesystemPath(const QString &source); + +public slots: + void setSource(const QString &source); + void setLoops(int loops); + void play(); + void stop(); + +signals: + void sourceChanged(); + void loopsChanged(); + void playingChanged(); + +private: + void onBackendPlaybackFinished(); + void setPlaying(bool playing); + void bindBackend(); + + std::unique_ptr _backend; + QString _source; + QString _resolvedFilePath; + int _loops = 1; + int _remainingLoops = 0; + bool _playing = false; +}; + +} diff --git a/src/gui/notificationsoundplayer_linux.cpp b/src/gui/notificationsoundplayer_linux.cpp new file mode 100644 index 0000000000000..5418b12f994bd --- /dev/null +++ b/src/gui/notificationsoundplayer_linux.cpp @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "notificationsoundplayer.h" +#include "notificationsoundplayer_p.h" + +#include +#include +#include + +#ifdef HAVE_LIBCANBERRA +#include +#endif + +namespace OCC { + +Q_LOGGING_CATEGORY(lcNotificationSoundPlayerLinux, "nextcloud.gui.notificationsoundplayer.linux", QtInfoMsg) + +namespace { + +#ifdef HAVE_LIBCANBERRA + +class LinuxBackend : public NotificationSoundPlayer::Backend +{ +public: + LinuxBackend() + { + const int rc = ca_context_create(&_context); + if (rc != CA_SUCCESS) { + qCWarning(lcNotificationSoundPlayerLinux) << "ca_context_create failed:" << ca_strerror(rc); + _context = nullptr; + } + } + + ~LinuxBackend() override + { + if (_context) { + ca_context_cancel(_context, kEventId); + // ca_context_destroy joins the worker thread, so no callback + // can fire after this returns. + ca_context_destroy(_context); + _context = nullptr; + } + } + + void setSource(const QString &filesystemPath) override + { + _filesystemPath = filesystemPath.toUtf8(); + } + + void play(int /*loops*/) override + { + if (!_context || _filesystemPath.isEmpty()) { + signalFinishedAsync(); + return; + } + + ca_proplist *props = nullptr; + if (ca_proplist_create(&props) != CA_SUCCESS) { + signalFinishedAsync(); + return; + } + ca_proplist_sets(props, CA_PROP_MEDIA_FILENAME, _filesystemPath.constData()); + ca_proplist_sets(props, CA_PROP_MEDIA_ROLE, "event"); + ca_proplist_sets(props, CA_PROP_CANBERRA_CACHE_CONTROL, "permanent"); + ca_proplist_sets(props, CA_PROP_EVENT_ID, "phone-incoming-call"); + + const int rc = ca_context_play_full(_context, kEventId, props, &finishedTrampoline, this); + ca_proplist_destroy(props); + + if (rc != CA_SUCCESS) { + qCWarning(lcNotificationSoundPlayerLinux) + << "ca_context_play_full failed:" << ca_strerror(rc); + signalFinishedAsync(); + } + } + + void stop() override + { + if (_context) { + ca_context_cancel(_context, kEventId); + } + } + + [[nodiscard]] bool handlesLoopsNatively() const override { return false; } + +private: + static void finishedTrampoline(ca_context * /*ctx*/, uint32_t /*id*/, int errorCode, void *userdata) + { + // Fires on the libcanberra worker thread. The userdata pointer is + // valid because ~LinuxBackend() blocks in ca_context_destroy() until + // any in-flight callback has returned. + if (errorCode == CA_ERROR_CANCELED) { + // Cancellation (via stop() or destruction) is not a natural end of + // playback — never advance the dispatcher's loop bookkeeping for it. + return; + } + auto *self = static_cast(userdata); + if (self->onFinished) { + self->onFinished(); + } + } + + void signalFinishedAsync() + { + if (!onFinished) { + return; + } + auto cb = onFinished; + QMetaObject::invokeMethod(qApp, [cb]() { cb(); }, Qt::QueuedConnection); + } + + ca_context *_context = nullptr; + QByteArray _filesystemPath; + static constexpr uint32_t kEventId = 1; +}; + +#else // !HAVE_LIBCANBERRA + +class LinuxNoOpBackend : public NotificationSoundPlayer::Backend +{ +public: + void setSource(const QString &) override {} + + void play(int) override + { + if (!_warnedOnce) { + qCWarning(lcNotificationSoundPlayerLinux) + << "libcanberra was not available at build time; notification sound is silent"; + _warnedOnce = true; + } + if (onFinished) { + auto cb = onFinished; + QMetaObject::invokeMethod(qApp, [cb]() { cb(); }, Qt::QueuedConnection); + } + } + + void stop() override {} + + [[nodiscard]] bool handlesLoopsNatively() const override { return true; } + +private: + bool _warnedOnce = false; +}; + +#endif // HAVE_LIBCANBERRA + +} + +std::unique_ptr createNotificationSoundPlayerBackend() +{ +#ifdef HAVE_LIBCANBERRA + return std::make_unique(); +#else + return std::make_unique(); +#endif +} + +} diff --git a/src/gui/notificationsoundplayer_mac.mm b/src/gui/notificationsoundplayer_mac.mm new file mode 100644 index 0000000000000..c6703d9848fb6 --- /dev/null +++ b/src/gui/notificationsoundplayer_mac.mm @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import +#import + +#include "notificationsoundplayer.h" +#include "notificationsoundplayer_p.h" + +#include +#include + +@interface NCNotificationSoundPlayerDelegate : NSObject +@property (nonatomic, copy) void(^onFinished)(void); +@end + +@implementation NCNotificationSoundPlayerDelegate + +- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag +{ + Q_UNUSED(player) + Q_UNUSED(flag) + if (self.onFinished) { + self.onFinished(); + } +} + +- (void)dealloc +{ + [_onFinished release]; + [super dealloc]; +} + +@end + +namespace OCC { + +Q_LOGGING_CATEGORY(lcNotificationSoundPlayerMac, "nextcloud.gui.notificationsoundplayer.mac", QtInfoMsg) + +namespace { + +class MacBackend : public NotificationSoundPlayer::Backend +{ +public: + MacBackend() + { + @autoreleasepool { + _delegate = [[NCNotificationSoundPlayerDelegate alloc] init]; + auto *self_ = this; + _delegate.onFinished = ^{ + if (self_->onFinished) { + self_->onFinished(); + } + }; + } + } + + ~MacBackend() override + { + @autoreleasepool { + _delegate.onFinished = nil; + if (_player) { + [_player stop]; + [_player setDelegate:nil]; + [_player release]; + _player = nil; + } + [_delegate release]; + _delegate = nil; + } + } + + void setSource(const QString &filesystemPath) override + { + @autoreleasepool { + if (_player) { + [_player stop]; + [_player setDelegate:nil]; + [_player release]; + _player = nil; + } + + NSString *path = filesystemPath.toNSString(); + NSURL *url = [NSURL fileURLWithPath:path]; + NSError *error = nil; + _player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&error]; + + if (!_player) { + qCWarning(lcNotificationSoundPlayerMac) + << "Could not load sound from" << filesystemPath + << "-" << QString::fromNSString([error localizedDescription]); + return; + } + + [_player setDelegate:_delegate]; + [_player prepareToPlay]; + } + } + + void play(int loops) override + { + @autoreleasepool { + if (!_player) { + return; + } + [_player setNumberOfLoops:(loops - 1)]; + [_player setCurrentTime:0]; + [_player play]; + } + } + + void stop() override + { + @autoreleasepool { + if (_player) { + [_player stop]; + } + } + } + + [[nodiscard]] bool handlesLoopsNatively() const override { return true; } + +private: + AVAudioPlayer *_player = nil; + NCNotificationSoundPlayerDelegate *_delegate = nil; +}; + +} + +std::unique_ptr createNotificationSoundPlayerBackend() +{ + return std::make_unique(); +} + +} diff --git a/src/gui/notificationsoundplayer_p.h b/src/gui/notificationsoundplayer_p.h new file mode 100644 index 0000000000000..8c9587d760878 --- /dev/null +++ b/src/gui/notificationsoundplayer_p.h @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "notificationsoundplayer.h" + +#include +#include + +namespace OCC { + +class NotificationSoundPlayer::Backend +{ +public: + virtual ~Backend() = default; + + virtual void setSource(const QString &filesystemPath) = 0; + + // For natively-looping backends (macOS, Windows): play the entire + // sequence of `loops` plays in one shot; invoke onFinished once at the + // end. For per-play backends (Linux libcanberra): play once; invoke + // onFinished after that single play (`loops` is ignored). The + // dispatcher re-issues play() until the requested loop count is met. + virtual void play(int loops) = 0; + + virtual void stop() = 0; + + // Return true if play(loops) plays the whole sequence in one shot and + // invokes onFinished only when the sequence is over. Return false if + // play() plays exactly once and the dispatcher must re-issue play() + // to drive the loop forward. + [[nodiscard]] virtual bool handlesLoopsNatively() const = 0; + + // Set by the dispatcher after construction. Backends must guarantee + // that no invocation can race with their destructor — destruction must + // join/cancel any worker threads first. + std::function onFinished; +}; + +// Defined per-platform in notificationsoundplayer_{mac,win,linux}.cpp/.mm +// (or in notificationsoundplayer.cpp as a no-op fallback when no native +// backend is selected by the build). +std::unique_ptr createNotificationSoundPlayerBackend(); + +} diff --git a/src/gui/notificationsoundplayer_win.cpp b/src/gui/notificationsoundplayer_win.cpp new file mode 100644 index 0000000000000..53dcba3472787 --- /dev/null +++ b/src/gui/notificationsoundplayer_win.cpp @@ -0,0 +1,281 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#define WIN32_LEAN_AND_MEAN +#include +#include + +#include "notificationsoundplayer.h" +#include "notificationsoundplayer_p.h" + +#include +#include +#include +#include + +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcNotificationSoundPlayerWin, "nextcloud.gui.notificationsoundplayer.win", QtInfoMsg) + +namespace { + +// Parse a RIFF/WAVE PCM file into a WAVEFORMATEX descriptor and the byte +// range covering the audio samples. Accepts 8/16/24-bit PCM, mono or stereo, +// at any sample rate. Rejects everything else with a logged reason so a +// future asset swap surfaces an actionable error instead of silent silence. +bool parseWavFile(const QByteArray &bytes, + WAVEFORMATEX &format, + quint32 &audioDataOffset, + quint32 &audioDataSize) +{ + const auto fail = [](const char *reason) { + qCWarning(lcNotificationSoundPlayerWin) << "Cannot parse WAV file:" << reason; + return false; + }; + + const auto totalSize = static_cast(bytes.size()); + if (totalSize < 44) { + return fail("file is shorter than the smallest possible RIFF/WAVE header"); + } + const char *data = bytes.constData(); + if (std::memcmp(data, "RIFF", 4) != 0 || std::memcmp(data + 8, "WAVE", 4) != 0) { + return fail("not a RIFF/WAVE file"); + } + + quint32 offset = 12; + bool fmtFound = false; + bool dataFound = false; + + while (offset + 8 <= totalSize) { + char chunkId[4]; + std::memcpy(chunkId, data + offset, 4); + quint32 chunkSize = 0; + std::memcpy(&chunkSize, data + offset + 4, 4); + offset += 8; + if (offset + chunkSize > totalSize) { + return fail("chunk extends past end of file"); + } + + if (std::memcmp(chunkId, "fmt ", 4) == 0) { + if (chunkSize < 16) { + return fail("fmt chunk too small"); + } + quint16 formatTag = 0; + quint16 channels = 0; + quint32 sampleRate = 0; + quint32 byteRate = 0; + quint16 blockAlign = 0; + quint16 bitsPerSample = 0; + std::memcpy(&formatTag, data + offset, 2); + std::memcpy(&channels, data + offset + 2, 2); + std::memcpy(&sampleRate, data + offset + 4, 4); + std::memcpy(&byteRate, data + offset + 8, 4); + std::memcpy(&blockAlign, data + offset + 12, 2); + std::memcpy(&bitsPerSample, data + offset + 14, 2); + + if (formatTag != WAVE_FORMAT_PCM) { + return fail("only uncompressed PCM is supported"); + } + if (channels != 1 && channels != 2) { + return fail("only mono and stereo are supported"); + } + if (bitsPerSample != 8 && bitsPerSample != 16 && bitsPerSample != 24) { + return fail("only 8-, 16- and 24-bit PCM are supported"); + } + + std::memset(&format, 0, sizeof(format)); + format.wFormatTag = WAVE_FORMAT_PCM; + format.nChannels = channels; + format.nSamplesPerSec = sampleRate; + format.nAvgBytesPerSec = byteRate; + format.nBlockAlign = blockAlign; + format.wBitsPerSample = bitsPerSample; + format.cbSize = 0; + fmtFound = true; + } else if (std::memcmp(chunkId, "data", 4) == 0) { + audioDataOffset = offset; + audioDataSize = chunkSize; + dataFound = true; + } + + offset += chunkSize; + // RIFF chunks are padded to even size. + if (chunkSize & 1u) { + ++offset; + } + } + + if (!fmtFound) { + return fail("no fmt chunk"); + } + if (!dataFound) { + return fail("no data chunk"); + } + return true; +} + +class VoiceCallback : public IXAudio2VoiceCallback +{ +public: + std::function onBufferEnd; + + void STDMETHODCALLTYPE OnVoiceProcessingPassStart(UINT32) override {} + void STDMETHODCALLTYPE OnVoiceProcessingPassEnd() override {} + void STDMETHODCALLTYPE OnStreamEnd() override {} + void STDMETHODCALLTYPE OnBufferStart(void *) override {} + void STDMETHODCALLTYPE OnBufferEnd(void *) override + { + if (onBufferEnd) { + onBufferEnd(); + } + } + void STDMETHODCALLTYPE OnLoopEnd(void *) override {} + void STDMETHODCALLTYPE OnVoiceError(void *, HRESULT) override {} +}; + +class WinBackend : public NotificationSoundPlayer::Backend +{ +public: + WinBackend() + { + HRESULT hr = XAudio2Create(&_xaudio2, 0, XAUDIO2_DEFAULT_PROCESSOR); + if (FAILED(hr)) { + qCWarning(lcNotificationSoundPlayerWin) << "XAudio2Create failed, hr =" << QString::number(hr, 16); + return; + } + hr = _xaudio2->CreateMasteringVoice(&_masteringVoice); + if (FAILED(hr)) { + qCWarning(lcNotificationSoundPlayerWin) << "CreateMasteringVoice failed, hr =" + << QString::number(hr, 16); + _xaudio2->Release(); + _xaudio2 = nullptr; + return; + } + _callback.onBufferEnd = [this]() { + if (onFinished) { + onFinished(); + } + }; + } + + ~WinBackend() override + { + if (_sourceVoice) { + _sourceVoice->Stop(0); + _sourceVoice->FlushSourceBuffers(); + // DestroyVoice blocks until all callbacks for this voice complete, + // so `_callback` is safe to drop afterwards. + _sourceVoice->DestroyVoice(); + _sourceVoice = nullptr; + } + _callback.onBufferEnd = nullptr; + if (_masteringVoice) { + _masteringVoice->DestroyVoice(); + _masteringVoice = nullptr; + } + if (_xaudio2) { + _xaudio2->Release(); + _xaudio2 = nullptr; + } + } + + void setSource(const QString &filesystemPath) override + { + if (!_xaudio2) { + return; + } + + QFile file(filesystemPath); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcNotificationSoundPlayerWin) << "Could not open" << filesystemPath + << ":" << file.errorString(); + return; + } + _audioBytes = file.readAll(); + file.close(); + + if (!parseWavFile(_audioBytes, _waveFormat, _audioDataOffset, _audioDataSize)) { + _audioBytes.clear(); + return; + } + + if (_sourceVoice) { + _sourceVoice->Stop(0); + _sourceVoice->FlushSourceBuffers(); + _sourceVoice->DestroyVoice(); + _sourceVoice = nullptr; + } + + HRESULT hr = _xaudio2->CreateSourceVoice(&_sourceVoice, &_waveFormat, + 0, XAUDIO2_DEFAULT_FREQ_RATIO, + &_callback); + if (FAILED(hr)) { + qCWarning(lcNotificationSoundPlayerWin) << "CreateSourceVoice failed, hr =" + << QString::number(hr, 16); + _audioBytes.clear(); + } + } + + void play(int loops) override + { + if (!_sourceVoice || _audioBytes.isEmpty()) { + return; + } + _sourceVoice->Stop(0); + _sourceVoice->FlushSourceBuffers(); + + XAUDIO2_BUFFER buffer = {}; + buffer.AudioBytes = _audioDataSize; + buffer.pAudioData = reinterpret_cast(_audioBytes.constData() + _audioDataOffset); + buffer.Flags = XAUDIO2_END_OF_STREAM; + buffer.LoopBegin = 0; + buffer.LoopLength = 0; + // XAUDIO2_BUFFER::LoopCount counts ADDITIONAL plays after the initial + // pass: LoopCount=0 plays once, LoopCount=8 plays 9 times. + buffer.LoopCount = (loops > 1) ? static_cast(loops - 1) : 0; + + HRESULT hr = _sourceVoice->SubmitSourceBuffer(&buffer); + if (FAILED(hr)) { + qCWarning(lcNotificationSoundPlayerWin) << "SubmitSourceBuffer failed, hr =" + << QString::number(hr, 16); + return; + } + hr = _sourceVoice->Start(0); + if (FAILED(hr)) { + qCWarning(lcNotificationSoundPlayerWin) << "Start failed, hr =" << QString::number(hr, 16); + } + } + + void stop() override + { + if (_sourceVoice) { + _sourceVoice->Stop(0); + _sourceVoice->FlushSourceBuffers(); + } + } + + [[nodiscard]] bool handlesLoopsNatively() const override { return true; } + +private: + IXAudio2 *_xaudio2 = nullptr; + IXAudio2MasteringVoice *_masteringVoice = nullptr; + IXAudio2SourceVoice *_sourceVoice = nullptr; + WAVEFORMATEX _waveFormat = {}; + QByteArray _audioBytes; + quint32 _audioDataOffset = 0; + quint32 _audioDataSize = 0; + VoiceCallback _callback; +}; + +} + +std::unique_ptr createNotificationSoundPlayerBackend() +{ + return std::make_unique(); +} + +} diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index ea065d2758fab..bc5a48b527aad 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -12,6 +12,7 @@ #include "application.h" #include "callstatechecker.h" #include "emojimodel.h" +#include "notificationsoundplayer.h" #include "fileactivitylistmodel.h" #include "folderman.h" #include "guiutility.h" @@ -141,6 +142,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedActivityListModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "WheelHandler"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "CallStateChecker"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "NotificationSoundPlayer"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "DateFieldBackend"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileDetails"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareModel"); diff --git a/src/gui/tray/CallNotificationDialog.qml b/src/gui/tray/CallNotificationDialog.qml index 208bc28b6b6dd..3c9e5361e3be4 100644 --- a/src/gui/tray/CallNotificationDialog.qml +++ b/src/gui/tray/CallNotificationDialog.qml @@ -8,7 +8,6 @@ import QtQuick.Window import Style import com.nextcloud.desktopclient import QtQuick.Layouts -import QtMultimedia import QtQuick.Controls import Qt5Compat.GraphicalEffects @@ -68,7 +67,7 @@ ApplicationWindow { onStopNotifying: root.closeNotification() } - SoundEffect { + NotificationSoundPlayer { id: ringSound source: root.ringtonePath loops: 9 // about 45 seconds of audio playing diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c4858f821bbe9..1f6d1ed152dc7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -94,6 +94,7 @@ nextcloud_add_test(Capabilities) nextcloud_add_test(PushNotifications) nextcloud_add_test(Theme) nextcloud_add_test(IconUtils) +nextcloud_add_test(NotificationSoundPlayer) nextcloud_add_test(SetUserStatusDialog) nextcloud_add_test(UnifiedSearchListmodel) nextcloud_add_test(ActivityListModel) diff --git a/test/testnotificationsoundplayer.cpp b/test/testnotificationsoundplayer.cpp new file mode 100644 index 0000000000000..2034d82296865 --- /dev/null +++ b/test/testnotificationsoundplayer.cpp @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gui/notificationsoundplayer.h" +#include "gui/notificationsoundplayer_p.h" + +using namespace OCC; + +namespace { + +class TestBackend : public NotificationSoundPlayer::Backend +{ +public: + explicit TestBackend(bool nativeLoops) + : _nativeLoops(nativeLoops) + { + } + + void setSource(const QString &filesystemPath) override { source = filesystemPath; } + void play(int loops) override + { + ++playCallCount; + lastLoopsArg = loops; + } + void stop() override { ++stopCallCount; } + [[nodiscard]] bool handlesLoopsNatively() const override { return _nativeLoops; } + + void fireFinished() + { + if (onFinished) { + onFinished(); + } + } + + QString source; + int playCallCount = 0; + int stopCallCount = 0; + int lastLoopsArg = -1; + +private: + bool _nativeLoops; +}; + +} + +class TestNotificationSoundPlayer : public QObject +{ + Q_OBJECT + +private: + static QString writeTempSoundFile() + { + auto *tmp = new QTemporaryFile(QDir::tempPath() + QStringLiteral("/nc-sound-test-XXXXXX.wav")); + tmp->setAutoRemove(true); + if (!tmp->open()) { + delete tmp; + return {}; + } + tmp->write("RIFF\0\0\0\0WAVE", 12); + const auto path = tmp->fileName(); + tmp->close(); + // Leak deliberately: keep the file alive for the test's lifetime + // by attaching it as a child of qApp. + tmp->setParent(qApp); + return path; + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + // nextcloudCore is statically linked into the test binary, so the QRC + // collections it embeds (`resources.qrc` and the generated `theme.qrc`) + // need to be initialised explicitly before any `qrc:` lookup runs. + Q_INIT_RESOURCE(resources); + Q_INIT_RESOURCE(theme); + } + + // ----- resolveToFilesystemPath ----- + + void resolve_qrcExtractsToCache() + { + const auto path = NotificationSoundPlayer::resolveToFilesystemPath( + QStringLiteral("qrc:///client/theme/call-notification.wav")); + QVERIFY(!path.isEmpty()); + QVERIFY(QFile::exists(path)); + } + + void resolve_qrcIsIdempotent() + { + const auto first = NotificationSoundPlayer::resolveToFilesystemPath( + QStringLiteral("qrc:///client/theme/call-notification.wav")); + const auto second = NotificationSoundPlayer::resolveToFilesystemPath( + QStringLiteral("qrc:///client/theme/call-notification.wav")); + QCOMPARE(first, second); + } + + void resolve_fileUrlReturnsLocalPath() + { + QTemporaryFile tmp; + QVERIFY(tmp.open()); + tmp.close(); + const auto fileUrl = QUrl::fromLocalFile(tmp.fileName()).toString(); + const auto resolved = NotificationSoundPlayer::resolveToFilesystemPath(fileUrl); + QCOMPARE(resolved, tmp.fileName()); + } + + void resolve_plainPathReturnsAsIs() + { + QTemporaryFile tmp; + QVERIFY(tmp.open()); + tmp.close(); + const auto resolved = NotificationSoundPlayer::resolveToFilesystemPath(tmp.fileName()); + QCOMPARE(resolved, tmp.fileName()); + } + + void resolve_emptyReturnsEmpty() + { + QCOMPARE(NotificationSoundPlayer::resolveToFilesystemPath({}), QString()); + } + + void resolve_nonexistentPathReturnsEmpty() + { + QCOMPARE(NotificationSoundPlayer::resolveToFilesystemPath(QStringLiteral("/definitely/does/not/exist.wav")), + QString()); + } + + // ----- Loop bookkeeping ----- + + void loops_perPlayBackendReissuesPlayUntilExhausted() + { + const auto soundPath = writeTempSoundFile(); + QVERIFY(!soundPath.isEmpty()); + + auto backendOwned = std::make_unique(/*nativeLoops*/ false); + auto *backend = backendOwned.get(); + NotificationSoundPlayer player(std::move(backendOwned), this); + player.setSource(soundPath); + player.setLoops(3); + + player.play(); + QCOMPARE(backend->playCallCount, 1); + QVERIFY(player.isPlaying()); + + backend->fireFinished(); + QCoreApplication::processEvents(); + QCOMPARE(backend->playCallCount, 2); + QVERIFY(player.isPlaying()); + + backend->fireFinished(); + QCoreApplication::processEvents(); + QCOMPARE(backend->playCallCount, 3); + QVERIFY(player.isPlaying()); + + backend->fireFinished(); + QCoreApplication::processEvents(); + QCOMPARE(backend->playCallCount, 3); + QVERIFY(!player.isPlaying()); + } + + void loops_nativeBackendDoesNotReissuePlay() + { + const auto soundPath = writeTempSoundFile(); + QVERIFY(!soundPath.isEmpty()); + + auto backendOwned = std::make_unique(/*nativeLoops*/ true); + auto *backend = backendOwned.get(); + NotificationSoundPlayer player(std::move(backendOwned), this); + player.setSource(soundPath); + player.setLoops(9); + + player.play(); + QCOMPARE(backend->playCallCount, 1); + QCOMPARE(backend->lastLoopsArg, 9); + QVERIFY(player.isPlaying()); + + backend->fireFinished(); + QCoreApplication::processEvents(); + QCOMPARE(backend->playCallCount, 1); + QVERIFY(!player.isPlaying()); + } + + void loops_stopMidSequenceCancelsRemainingPlays() + { + const auto soundPath = writeTempSoundFile(); + QVERIFY(!soundPath.isEmpty()); + + auto backendOwned = std::make_unique(/*nativeLoops*/ false); + auto *backend = backendOwned.get(); + NotificationSoundPlayer player(std::move(backendOwned), this); + player.setSource(soundPath); + player.setLoops(5); + + player.play(); + backend->fireFinished(); + QCoreApplication::processEvents(); + QCOMPARE(backend->playCallCount, 2); + + player.stop(); + QCOMPARE(backend->stopCallCount, 1); + QVERIFY(!player.isPlaying()); + + // A late finished callback that races the stop must not re-issue play. + backend->fireFinished(); + QCoreApplication::processEvents(); + QCOMPARE(backend->playCallCount, 2); + QVERIFY(!player.isPlaying()); + } + + void play_emptySourceIsNoop() + { + auto backendOwned = std::make_unique(true); + auto *backend = backendOwned.get(); + NotificationSoundPlayer player(std::move(backendOwned), this); + player.setLoops(3); + + player.play(); + QCOMPARE(backend->playCallCount, 0); + QVERIFY(!player.isPlaying()); + } + + void play_zeroLoopsIsNoop() + { + const auto soundPath = writeTempSoundFile(); + QVERIFY(!soundPath.isEmpty()); + + auto backendOwned = std::make_unique(true); + auto *backend = backendOwned.get(); + NotificationSoundPlayer player(std::move(backendOwned), this); + player.setSource(soundPath); + player.setLoops(0); + + player.play(); + QCOMPARE(backend->playCallCount, 0); + QVERIFY(!player.isPlaying()); + } +}; + +QTEST_MAIN(TestNotificationSoundPlayer) +#include "testnotificationsoundplayer.moc"