/* * Copyright (c) 2010-2024 Belledonne Communications SARL. * * This file is part of linphone-desktop * (see https://www.linphone.org). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include #include #include #include #include #include #include #include #include #include #include "Notifier.hpp" #include "core/App.hpp" #include "core/call/CallGui.hpp" #include "core/chat/ChatGui.hpp" #include "model/tool/ToolModel.hpp" #include "tool/LinphoneEnums.hpp" #include "tool/accessibility/AccessibilityHelper.hpp" #include "tool/providers/AvatarProvider.hpp" #include "tool/providers/ImageProvider.hpp" DEFINE_ABSTRACT_OBJECT(Notifier) // ============================================================================= using namespace std; namespace { constexpr char NotificationsPath[] = "qrc:/qt/qml/Linphone/view/Control/Popup/Notification/"; // --------------------------------------------------------------------------- // Notifications QML properties/methods. // --------------------------------------------------------------------------- constexpr char NotificationShowMethodName[] = "open"; constexpr char NotificationPropertyData[] = "notificationData"; constexpr char NotificationPropertyX[] = "popupX"; constexpr char NotificationPropertyY[] = "popupY"; constexpr char NotificationPropertyWindow[] = "__internalWindow"; constexpr char NotificationPropertyTimer[] = "__timer"; // --------------------------------------------------------------------------- // Arbitrary hardcoded values. // --------------------------------------------------------------------------- constexpr int NotificationSpacing = 10; constexpr int MaxNotificationsNumber = 5; } // namespace // ============================================================================= template void setProperty(QObject &object, const char *property, const T &value) { if (!object.setProperty(property, QVariant(value))) { qWarning() << QStringLiteral("Unable to set property: `%1`.").arg(property); abort(); } } // ============================================================================= // Available notifications. // ============================================================================= const QHash Notifier::Notifications = { {Notifier::ReceivedMessage, {Notifier::ReceivedMessage, "NotificationReceivedMessage.qml", 10}}, //{Notifier::ReceivedFileMessage, {Notifier::ReceivedFileMessage, "NotificationReceivedFileMessage.qml", 10}}, {Notifier::ReceivedCall, {Notifier::ReceivedCall, "NotificationReceivedCall.qml", 30}} //{Notifier::NewVersionAvailable, {Notifier::NewVersionAvailable, "NotificationNewVersionAvailable.qml", 30}}, //{Notifier::SnapshotWasTaken, {Notifier::SnapshotWasTaken, "NotificationSnapshotWasTaken.qml", 10}}, //{Notifier::RecordingCompleted, {Notifier::RecordingCompleted, "NotificationRecordingCompleted.qml", 10}} }; // ----------------------------------------------------------------------------- Notifier::Notifier(QObject *parent) : QObject(parent) { mustBeInMainThread(getClassName()); const int nComponents = Notifications.size(); mComponents.resize(nComponents); QQmlEngine *engine = App::getInstance()->mEngine; for (const auto &key : Notifications.keys()) { QQmlComponent *component = new QQmlComponent(engine, QUrl(NotificationsPath + Notifier::Notifications[key].filename)); if (Q_UNLIKELY(component->isError())) { qWarning() << QStringLiteral("Errors found in `Notification` component %1:").arg(key) << component->errors(); abort(); } mComponents[key] = component; } mMutex = new QMutex(); } Notifier::~Notifier() { mustBeInMainThread("~" + getClassName()); delete mMutex; const int nComponents = Notifications.size(); mComponents.clear(); } // ----------------------------------------------------------------------------- bool Notifier::createNotification(Notifier::NotificationType type, QVariantMap data) { mMutex->lock(); // Q_ASSERT(mInstancesNumber <= MaxNotificationsNumber); if (mInstancesNumber == MaxNotificationsNumber) { // Check existing instances. qWarning() << QStringLiteral("Unable to create another notification."); mMutex->unlock(); return false; } QList allScreens = QGuiApplication::screens(); if (allScreens.size() > 0) { // Ensure to have a screen to avoid errors QQuickItem *previousWrapper = nullptr; bool showAsTool = false; #ifdef Q_OS_MACOS for (auto w : QGuiApplication::topLevelWindows()) { if ((w->windowState() & Qt::WindowFullScreen) == Qt::WindowFullScreen) { showAsTool = true; w->raise(); // Used to get focus on Mac (On Mac, A Tool is hidden if the app has not focus and the only // way to rid it is to use Widget Attributes(Qt::WA_MacAlwaysShowToolWindow) that is not // available) } } #endif for (int i = 0; i < allScreens.size(); ++i) { ++mInstancesNumber; // Use QQuickView to create a visual root object that is // independant from current application Window QScreen *screen = allScreens[i]; auto engine = App::getInstance()->mEngine; const QUrl url(QString(NotificationsPath) + Notifier::Notifications[type].filename); QObject::connect( engine, &QQmlApplicationEngine::objectCreated, this, [this, url, screen, engine, type, data](QObject *obj, const QUrl &objUrl) { if (!obj && url == objUrl) { lCritical() << "[App] Notifier.qml couldn't be load."; engine->deleteLater(); exit(-1); } else { auto window = qobject_cast(obj); if (window) { window->setProperty(NotificationPropertyData, data); // for (auto it = data.begin(); it != data.end(); ++it) // window->setProperty(it.key().toLatin1(), it.value()); const int timeout = Notifications[type].getTimeout() * 1000; auto updateNotificationCoordinates = [this, window, screen](int width, int height) { auto screenHeightOffset = screen ? mScreenHeightOffset.value(screen->name()) : 0; // Access optimization QRect availableGeometry = screen->availableGeometry(); window->setX(availableGeometry.x() + (availableGeometry.width() - width)); //*screen->devicePixelRatio()); when using manual scaler window->setY(availableGeometry.y() + availableGeometry.height() - screenHeightOffset - height); }; updateNotificationCoordinates(window->width(), window->height()); auto screenHeightOffset = screen ? mScreenHeightOffset.take(screen->name()) : 0; // Access optimization mScreenHeightOffset.insert(screen->name(), screenHeightOffset + window->height()); QObject::connect(window, &QQuickWindow::closing, window, [this, window] { qDebug() << "closing notification"; deleteNotification(QVariant::fromValue(window)); }); showNotification(window, timeout); lInfo() << QStringLiteral("Create notification:") << QVariant::fromValue(window); } } }, static_cast(Qt::QueuedConnection | Qt::SingleShotConnection)); lDebug() << log().arg("Engine loading notification"); engine->load(url); } } mMutex->unlock(); return true; } // ----------------------------------------------------------------------------- void Notifier::showNotification(QObject *notification, int timeout) { // Display notification. QTimer *timer = new QTimer(notification); timer->setInterval(timeout); timer->setSingleShot(true); notification->setProperty(NotificationPropertyTimer, QVariant::fromValue(timer)); // Destroy it after timeout. QObject::connect(timer, &QTimer::timeout, this, [this, notification]() { deleteNotificationOnTimeout(QVariant::fromValue(notification)); }); // Called explicitly (by a click on notification for example) QObject::connect(notification, SIGNAL(deleteNotification(QVariant)), this, SLOT(deleteNotification(QVariant))); timer->start(); } // ----------------------------------------------------------------------------- void Notifier::deleteNotificationOnTimeout(QVariant notification) { #ifdef Q_OS_MACOS for (auto w : QGuiApplication::topLevelWindows()) { if ((w->windowState() & Qt::WindowFullScreen) == Qt::WindowFullScreen) { w->requestActivate(); // Used to get focus on fullscreens on Mac in order to avoid screen switching. } } #endif deleteNotification(notification); } void Notifier::deleteNotification(QVariant notification) { mMutex->lock(); QObject *instance = notification.value(); // Notification marked destroyed. if (instance->property("__valid").isValid()) { mMutex->unlock(); return; } lInfo() << QStringLiteral("Delete notification:") << instance << --mInstancesNumber; instance->setProperty("__valid", true); auto timerProperty = instance->property(NotificationPropertyTimer).value(); if (timerProperty) timerProperty->stop(); Q_ASSERT(mInstancesNumber >= 0); if (mInstancesNumber == 0) mScreenHeightOffset.clear(); mMutex->unlock(); instance->deleteLater(); } // ============================================================================= #define CREATE_NOTIFICATION(TYPE, DATA) \ auto settings = App::getInstance()->getSettings(); \ if (settings && settings->dndEnabled()) return; \ createNotification(TYPE, DATA); // ----------------------------------------------------------------------------- // Notification functions. // ----------------------------------------------------------------------------- void Notifier::notifyReceivedCall(const shared_ptr &call) { mustBeInLinphoneThread(log().arg(Q_FUNC_INFO)); auto remoteAddress = call->getRemoteAddress(); auto accountSender = ToolModel::findAccount(remoteAddress); auto account = ToolModel::findAccount(call->getToAddress()); if (account) { auto accountModel = Utils::makeQObject_ptr(account); if (!accountModel->getNotificationsAllowed()) { lInfo() << log().arg( "Notifications have been disabled for this account - not creating a notification for incoming call"); if (accountModel->forwardToVoiceMailInDndPresence()) { lInfo() << log().arg("Transferring call to voicemail"); auto voicemailAddress = linphone::Factory::get()->createAddress( Utils::appStringToCoreString(accountModel->getVoicemailAddress())); if (voicemailAddress) call->transferTo(voicemailAddress); } else { lInfo() << log().arg("Declining call."); call->decline(linphone::Reason::Busy); } return; } accountModel->deleteLater(); } auto model = CallCore::create(call); auto gui = new CallGui(model); gui->moveToThread(App::getInstance()->thread()); auto callLog = call->getCallLog(); auto displayName = callLog && callLog->getConferenceInfo() ? Utils::coreStringToAppString(callLog->getConferenceInfo()->getSubject()) : ToolModel::getDisplayName(call->getRemoteAddress()); // Accessibility alert //: New call from %1 AccessibilityHelper::announceMessage(tr("new_call_alert_accessible_name").arg(displayName)); App::postCoreAsync([this, gui, displayName]() { mustBeInMainThread(getClassName()); QVariantMap map; map["displayName"].setValue(displayName); map["call"].setValue(gui); CREATE_NOTIFICATION(Notifier::ReceivedCall, map) }); } void Notifier::notifyReceivedMessages(const std::shared_ptr &room, const list> &messages) { mustBeInLinphoneThread(log().arg(Q_FUNC_INFO)); if (room->getMuted()) return; QString txt; QString remoteAddress; if (messages.size() > 0) { shared_ptr message = messages.front(); auto receiverAccount = ToolModel::findAccount(message->getToAddress()); if (receiverAccount) { auto senderAccount = ToolModel::findAccount(message->getFromAddress()); auto currentAccount = CoreModel::getInstance()->getCore()->getDefaultAccount(); if (senderAccount && senderAccount->getContactAddress()->weakEqual(currentAccount->getContactAddress())) { qDebug() << "sender is current account, return"; return; } auto accountModel = Utils::makeQObject_ptr(receiverAccount); if (!accountModel->getNotificationsAllowed()) { lInfo() << log().arg( "Notifications have been disabled for this account - not creating a notification for " "incoming message"); return; } accountModel->deleteLater(); } auto getMessage = [this, &remoteAddress, &txt](const shared_ptr &message) { if (message->isRead()) return; auto remoteAddr = message->getFromAddress(); // remoteAddr->clean(); remoteAddress = Utils::coreStringToAppString(remoteAddr->asStringUriOnly()); auto fileContent = message->getFileTransferInformation(); if (!fileContent) { for (auto content : message->getContents()) { if (content->isText()) txt += content->getUtf8Text().c_str(); } } else if (fileContent->isVoiceRecording()) //: 'Voice message received!' : message to warn the user in a notofication for voice messages. txt = tr("new_voice_message"); else txt = tr("new_file_message"); if (txt.isEmpty() && message->hasConferenceInvitationContent()) //: 'Conference invitation received!' : Notification about receiving an invitation to a conference. txt = tr("new_conference_invitation"); }; if (messages.size() == 1) { // Display only sender on mono message. if (message->isRead()) return; getMessage(message); if (txt.isEmpty()) { // Do not notify message without content qDebug() << "empty notif, return"; return; } } else { int unreadCount = 0; for (auto &message : messages) { if (!message->isRead()) { ++unreadCount; if (unreadCount == 1) getMessage(message); } } if (unreadCount == 0) return; if (unreadCount > 1) //: 'New messages received!' Notification that warn the user of new messages. txt = tr("new_chat_room_messages"); } // Play noitification sound auto settings = SettingsModel::getInstance(); if (settings && !settings->dndEnabled()) { CoreModel::getInstance()->getCore()->playLocal( Utils::appStringToCoreString(settings->getChatNotificationSoundPath())); } // If chat currently displayed, do not display notification auto currentlyDisplayedChat = App::getInstance()->getCurrentChat(); auto mainWin = App::getInstance()->getMainWindow(); if (currentlyDisplayedChat && mainWin->isActive()) { auto linphoneCurrent = currentlyDisplayedChat->mCore->getModel()->getMonitor(); if (linphoneCurrent->getIdentifier() == room->getIdentifier()) { lInfo() << log().arg("Chat is currently displayed in the view, do not show notification"); return; } } auto chatCore = ChatCore::create(room); App::postCoreAsync([this, txt, chatCore, remoteAddress]() { mustBeInMainThread(getClassName()); // Accessibility alert //: New message on chatroom %1 AccessibilityHelper::announceMessage(tr("new_message_alert_accessible_name").arg(chatCore->getTitle())); QVariantMap map; map["message"] = txt; map["remoteAddress"] = remoteAddress; map["chatRoomName"] = chatCore->getTitle(); map["chatRoomAddress"] = chatCore->getChatRoomAddress(); map["avatarUri"] = chatCore->getAvatarUri(); map["isGroupChat"] = chatCore->isGroupChat(); map["chat"] = QVariant::fromValue(chatCore ? new ChatGui(chatCore) : nullptr); CREATE_NOTIFICATION(Notifier::ReceivedMessage, map) }); } } /* void Notifier::notifyReceivedReactions( const QList, std::shared_ptr>> &reactions) { QVariantMap map; QString txt; if (reactions.size() > 0) { ChatMessageModel *redirection = nullptr; QPair, std::shared_ptr> reaction = reactions.front(); shared_ptr message = reaction.first; shared_ptr chatRoom(message->getChatRoom()); auto timelineModel = CoreManager::getInstance()->getTimelineListModel()->getTimeline(chatRoom, true); map["messageId"] = Utils::coreStringToAppString(message->getMessageId()); if (reactions.size() == 1) { QString messageTxt; auto fileContent = message->getFileTransferInformation(); if (!fileContent) { foreach (auto content, message->getContents()) { if (content->isText()) messageTxt += content->getUtf8Text().c_str(); } } else if (fileContent->isVoiceRecording()) //: 'Voice message' : Voice message type that has been reacted. messageTxt += tr("voice_message_react"); else { QFileInfo file(Utils::coreStringToAppString(fileContent->getFilePath())); messageTxt += file.fileName(); } if (messageTxt.isEmpty() && message->hasConferenceInvitationContent()) //: 'Conference invitation' : Conference invitation message type that has been reacted. messageTxt += tr("conference_invitation_react"); //: ''Has reacted by %1 to: %2' : Reaction message. %1=Reaction(emoji), %2=type of message(Voice //: Message/Conference invitation/ Message text) txt = tr("reaction_message").arg(Utils::coreStringToAppString(reaction.second->getBody())).arg(messageTxt); } else //: 'New reactions received!' : Notification that warn the user of new reactions. txt = tr("new_reactions_messages"); map["message"] = txt; map["timelineModel"].setValue(timelineModel.get()); if (reactions.size() == 1) { // Display only sender on mono message. map["remoteAddress"] = Utils::coreStringToAppString(reaction.second->getFromAddress()->asStringUriOnly()); map["fullremoteAddress"] = Utils::coreStringToAppString(reaction.second->getFromAddress()->asString()); } map["localAddress"] = Utils::coreStringToAppString(chatRoom->getLocalAddress()->asStringUriOnly()); map["fullLocalAddress"] = Utils::coreStringToAppString(chatRoom->getLocalAddress()->asString()); map["window"].setValue(App::getInstance()->getMainWindow()); CREATE_NOTIFICATION(Notifier::ReceivedMessage, map) } } void Notifier::notifyReceivedFileMessage(const shared_ptr &message, const shared_ptr &content) { QVariantMap map; shared_ptr chatRoom(message->getChatRoom()); map["timelineModel"].setValue( CoreManager::getInstance()->getTimelineListModel()->getTimeline(chatRoom, true).get()); map["fileUri"] = Utils::coreStringToAppString(content->getFilePath()); if (Utils::getImage(map["fileUri"].toString()).isNull()) map["imageUri"] = ""; else map["imageUri"] = map["fileUri"]; map["fileSize"] = quint64(content->getSize() + content->getFileSize()); CREATE_NOTIFICATION(Notifier::ReceivedFileMessage, map) } void Notifier::notifyNewVersionAvailable(const QString &version, const QString &url) { QVariantMap map; map["message"] = tr("new_version_available").arg(version); map["url"] = url; CREATE_NOTIFICATION(Notifier::NewVersionAvailable, map) } void Notifier::notifySnapshotWasTaken(const QString &filePath) { QVariantMap map; map["filePath"] = filePath; CREATE_NOTIFICATION(Notifier::SnapshotWasTaken, map) } void Notifier::notifyRecordingCompleted(const QString &filePath) { QVariantMap map; map["filePath"] = filePath; CREATE_NOTIFICATION(Notifier::RecordingCompleted, map) } */ #undef SHOW_NOTIFICATION #undef CREATE_NOTIFICATION