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"