diff --git a/.gitignore b/.gitignore index d905f1e3a..e05974b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ deploy/build_32/* deploy/build_64/* winbuild*.bat .cache/ +.vscode/ # Qt-es diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 78d2e3cb8..3b999aa13 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -82,7 +82,9 @@ apiDefs::ConfigSource apiUtils::getConfigSource(const QJsonObject &serverConfigO return static_cast(serverConfigObject.value(apiDefs::key::configVersion).toInt()); } -amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &sslErrors, QNetworkReply *reply) +amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &sslErrors, const QString &replyErrorString, + const QNetworkReply::NetworkError &replyError, const int httpStatusCode, + const QByteArray &responseBody) { const int httpStatusCodeConflict = 409; const int httpStatusCodeNotFound = 404; @@ -90,21 +92,19 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; return amnezia::ErrorCode::ApiConfigSslError; - } else if (reply->error() == QNetworkReply::NoError) { + } else if (replyError == QNetworkReply::NoError) { return amnezia::ErrorCode::NoError; - } else if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError - || reply->error() == QNetworkReply::NetworkError::TimeoutError) { - qDebug() << reply->error(); + } else if (replyError == QNetworkReply::NetworkError::OperationCanceledError + || replyError == QNetworkReply::NetworkError::TimeoutError) { + qDebug() << replyError; return amnezia::ErrorCode::ApiConfigTimeoutError; - } else if (reply->error() == QNetworkReply::NetworkError::OperationNotImplementedError) { - qDebug() << reply->error(); + } else if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) { + qDebug() << replyError; return amnezia::ErrorCode::ApiUpdateRequestError; } else { - QString err = reply->errorString(); - int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - qDebug() << QString::fromUtf8(reply->readAll()); - qDebug() << reply->error(); - qDebug() << err; + qDebug() << QString::fromUtf8(responseBody); + qDebug() << replyError; + qDebug() << replyErrorString; qDebug() << httpStatusCode; if (httpStatusCode == httpStatusCodeConflict) { return amnezia::ErrorCode::ApiConfigLimitError; diff --git a/client/core/api/apiUtils.h b/client/core/api/apiUtils.h index 45eaf2dec..b84d42e0c 100644 --- a/client/core/api/apiUtils.h +++ b/client/core/api/apiUtils.h @@ -18,7 +18,9 @@ namespace apiUtils apiDefs::ConfigType getConfigType(const QJsonObject &serverConfigObject); apiDefs::ConfigSource getConfigSource(const QJsonObject &serverConfigObject); - amnezia::ErrorCode checkNetworkReplyErrors(const QList &sslErrors, QNetworkReply *reply); + amnezia::ErrorCode checkNetworkReplyErrors(const QList &sslErrors, const QString &replyErrorString, + const QNetworkReply::NetworkError &replyError, const int httpStatusCode, + const QByteArray &responseBody); QString getPremiumV1VpnKey(const QJsonObject &serverConfigObject); } diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index cdc3b3709..22ab164f5 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -99,6 +99,9 @@ void CoreController::initModels() m_apiDevicesModel.reset(new ApiDevicesModel(m_settings, this)); m_engine->rootContext()->setContextProperty("ApiDevicesModel", m_apiDevicesModel.get()); + + m_newsModel.reset(new NewsModel(m_settings, this)); + m_engine->rootContext()->setContextProperty("NewsModel", m_newsModel.get()); } void CoreController::initControllers() @@ -153,6 +156,9 @@ void CoreController::initControllers() m_apiPremV1MigrationController.reset(new ApiPremV1MigrationController(m_serversModel, m_settings, this)); m_engine->rootContext()->setContextProperty("ApiPremV1MigrationController", m_apiPremV1MigrationController.get()); + + m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings, m_serversModel, this)); + m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get()); } void CoreController::initAndroidController() @@ -316,6 +322,11 @@ void CoreController::initContainerModelUpdateHandler() connect(m_serversModel.get(), &ServersModel::containersUpdated, m_containersModel.get(), &ContainersModel::updateModel); connect(m_serversModel.get(), &ServersModel::defaultServerContainersUpdated, m_defaultServerContainersModel.get(), &ContainersModel::updateModel); + connect(m_serversModel.get(), &ServersModel::gatewayStacksExpanded, this, [this]() { + if (m_serversModel->hasServersFromGatewayApi()) { + m_apiNewsController->fetchNews(); + } + }); m_serversModel->resetModel(); } diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 66ddb72fa..404a17828 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -12,6 +12,7 @@ #include "ui/controllers/api/apiConfigsController.h" #include "ui/controllers/api/apiSettingsController.h" #include "ui/controllers/api/apiPremV1MigrationController.h" +#include "ui/controllers/api/apiNewsController.h" #include "ui/controllers/appSplitTunnelingController.h" #include "ui/controllers/allowedDnsController.h" #include "ui/controllers/connectionController.h" @@ -47,6 +48,7 @@ #include "ui/models/services/sftpConfigModel.h" #include "ui/models/services/socks5ProxyConfigModel.h" #include "ui/models/sites_model.h" +#include "ui/models/newsModel.h" #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) #include "ui/notificationhandler.h" @@ -118,6 +120,7 @@ private: QScopedPointer m_apiSettingsController; QScopedPointer m_apiConfigsController; QScopedPointer m_apiPremV1MigrationController; + QScopedPointer m_apiNewsController; QSharedPointer m_containersModel; QSharedPointer m_defaultServerContainersModel; @@ -125,6 +128,7 @@ private: QSharedPointer m_languageModel; QSharedPointer m_protocolsModel; QSharedPointer m_sitesModel; + QSharedPointer m_newsModel; QSharedPointer m_allowedDnsModel; QSharedPointer m_appSplitTunnelingModel; QSharedPointer m_clientManagementModel; diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 54954063b..213f137d2 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -50,69 +50,6 @@ GatewayController::GatewayController(const QString &gatewayEndpoint, const bool { } -ErrorCode GatewayController::get(const QString &endpoint, QByteArray &responseBody) -{ -#ifdef Q_OS_IOS - IosController::Instance()->requestInetAccess(); - QThread::msleep(10); -#endif - - QNetworkRequest request; - request.setTransferTimeout(m_requestTimeoutMsecs); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader(QString("X-Client-Request-ID").toUtf8(), QUuid::createUuid().toString(QUuid::WithoutBraces).toUtf8()); - - request.setUrl(QString(endpoint).arg(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl)); - - // bypass killSwitch exceptions for API-gateway -#ifdef AMNEZIA_DESKTOP - if (m_isStrictKillSwitchEnabled) { - QString host = QUrl(request.url()).host(); - QString ip = NetworkUtilities::getIPAddress(host); - if (!ip.isEmpty()) { - IpcClient::Interface()->addKillSwitchAllowedRange(QStringList { ip }); - } - } -#endif - - QNetworkReply *reply; - reply = amnApp->networkManager()->get(request); - - QEventLoop wait; - QObject::connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); - - QList sslErrors; - connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); - wait.exec(); - - responseBody = reply->readAll(); - - if (sslErrors.isEmpty() && shouldBypassProxy(reply, responseBody, false)) { - auto requestFunction = [&request, &responseBody](const QString &url) { - request.setUrl(url); - return amnApp->networkManager()->get(request); - }; - - auto replyProcessingFunction = [&responseBody, &reply, &sslErrors, this](QNetworkReply *nestedReply, - const QList &nestedSslErrors) { - responseBody = nestedReply->readAll(); - if (!sslErrors.isEmpty() || !shouldBypassProxy(nestedReply, responseBody, false)) { - sslErrors = nestedSslErrors; - reply = nestedReply; - return true; - } - return false; - }; - - bypassProxy(endpoint, reply, requestFunction, replyProcessingFunction); - } - - auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, reply); - reply->deleteLater(); - - return errorCode; -} - ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody) { #ifdef Q_OS_IOS @@ -188,29 +125,35 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api wait.exec(); QByteArray encryptedResponseBody = reply->readAll(); + QString replyErrorString = reply->errorString(); + auto replyError = reply->error(); + int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (sslErrors.isEmpty() && shouldBypassProxy(reply, encryptedResponseBody, true, key, iv, salt)) { + reply->deleteLater(); + + if (sslErrors.isEmpty() && shouldBypassProxy(replyError, encryptedResponseBody, true, key, iv, salt)) { auto requestFunction = [&request, &encryptedResponseBody, &requestBody](const QString &url) { request.setUrl(url); return amnApp->networkManager()->post(request, QJsonDocument(requestBody).toJson()); }; - auto replyProcessingFunction = [&encryptedResponseBody, &reply, &sslErrors, &key, &iv, &salt, - this](QNetworkReply *nestedReply, const QList &nestedSslErrors) { - encryptedResponseBody = nestedReply->readAll(); - reply = nestedReply; - if (!sslErrors.isEmpty() || shouldBypassProxy(nestedReply, encryptedResponseBody, true, key, iv, salt)) { + auto replyProcessingFunction = [&encryptedResponseBody, &replyErrorString, &replyError, &httpStatusCode, &sslErrors, &key, &iv, + &salt, this](QNetworkReply *reply, const QList &nestedSslErrors) { + encryptedResponseBody = reply->readAll(); + replyErrorString = reply->errorString(); + replyError = reply->error(); + httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (!sslErrors.isEmpty() || shouldBypassProxy(replyError, encryptedResponseBody, true, key, iv, salt)) { sslErrors = nestedSslErrors; return false; } return true; }; - bypassProxy(endpoint, reply, requestFunction, replyProcessingFunction); + bypassProxy(endpoint, requestFunction, replyProcessingFunction); } - auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, reply); - reply->deleteLater(); + auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, encryptedResponseBody); if (errorCode) { return errorCode; } @@ -288,7 +231,11 @@ QStringList GatewayController::getProxyUrls() } return endpoints; } else { - apiUtils::checkNetworkReplyErrors(sslErrors, reply); + QByteArray responseBody = reply->readAll(); + QString replyErrorString = reply->errorString(); + auto replyError = reply->error(); + int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, responseBody); qDebug() << "go to the next storage endpoint"; reply->deleteLater(); @@ -297,33 +244,33 @@ QStringList GatewayController::getProxyUrls() return {}; } -bool GatewayController::shouldBypassProxy(QNetworkReply *reply, const QByteArray &responseBody, bool checkEncryption, const QByteArray &key, - const QByteArray &iv, const QByteArray &salt) +bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &responseBody, + bool checkEncryption, const QByteArray &key, const QByteArray &iv, const QByteArray &salt) { - if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError || reply->error() == QNetworkReply::NetworkError::TimeoutError) { + if (replyError == QNetworkReply::NetworkError::OperationCanceledError || replyError == QNetworkReply::NetworkError::TimeoutError) { qDebug() << "timeout occurred"; - qDebug() << reply->error(); + qDebug() << replyError; return true; } else if (responseBody.contains("html")) { qDebug() << "the response contains an html tag"; return true; - } else if (reply->error() == QNetworkReply::NetworkError::ContentNotFoundError) { + } else if (replyError == QNetworkReply::NetworkError::ContentNotFoundError) { if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2) || responseBody.contains(errorResponsePattern3)) { return false; } else { - qDebug() << reply->error(); + qDebug() << replyError; return true; } - } else if (reply->error() == QNetworkReply::NetworkError::OperationNotImplementedError) { + } else if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) { if (responseBody.contains(updateRequestResponsePattern)) { return false; } else { - qDebug() << reply->error(); + qDebug() << replyError; return true; } - } else if (reply->error() != QNetworkReply::NetworkError::NoError) { - qDebug() << reply->error(); + } else if (replyError != QNetworkReply::NetworkError::NoError) { + qDebug() << replyError; return true; } else if (checkEncryption) { try { @@ -337,8 +284,7 @@ bool GatewayController::shouldBypassProxy(QNetworkReply *reply, const QByteArray return false; } -void GatewayController::bypassProxy(const QString &endpoint, QNetworkReply *reply, - std::function requestFunction, +void GatewayController::bypassProxy(const QString &endpoint, std::function requestFunction, std::function &sslErrors)> replyProcessingFunction) { QStringList proxyUrls = getProxyUrls(); @@ -348,24 +294,22 @@ void GatewayController::bypassProxy(const QString &endpoint, QNetworkReply *repl QByteArray responseBody; - auto bypassFunction = [this](const QString &endpoint, const QString &proxyUrl, QNetworkReply *reply, + auto bypassFunction = [this](const QString &endpoint, const QString &proxyUrl, std::function requestFunction, std::function &sslErrors)> replyProcessingFunction) { QEventLoop wait; QList sslErrors; qDebug() << "go to the next proxy endpoint"; - reply->deleteLater(); // delete the previous reply - reply = requestFunction(endpoint.arg(proxyUrl)); + QNetworkReply *reply = requestFunction(endpoint.arg(proxyUrl)); QObject::connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); wait.exec(); - if (replyProcessingFunction(reply, sslErrors)) { - return true; - } - return false; + auto result = replyProcessingFunction(reply, sslErrors); + reply->deleteLater(); + return result; }; if (m_proxyUrl.isEmpty()) { @@ -399,13 +343,13 @@ void GatewayController::bypassProxy(const QString &endpoint, QNetworkReply *repl } if (!m_proxyUrl.isEmpty()) { - if (bypassFunction(endpoint, m_proxyUrl, reply, requestFunction, replyProcessingFunction)) { + if (bypassFunction(endpoint, m_proxyUrl, requestFunction, replyProcessingFunction)) { return; } } for (const QString &proxyUrl : proxyUrls) { - if (bypassFunction(endpoint, proxyUrl, reply, requestFunction, replyProcessingFunction)) { + if (bypassFunction(endpoint, proxyUrl, requestFunction, replyProcessingFunction)) { m_proxyUrl = proxyUrl; break; } diff --git a/client/core/controllers/gatewayController.h b/client/core/controllers/gatewayController.h index 4c247fc67..fc32dc4ae 100644 --- a/client/core/controllers/gatewayController.h +++ b/client/core/controllers/gatewayController.h @@ -18,14 +18,13 @@ public: explicit GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, const bool isStrictKillSwitchEnabled, QObject *parent = nullptr); - amnezia::ErrorCode get(const QString &endpoint, QByteArray &responseBody); amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody); private: QStringList getProxyUrls(); - bool shouldBypassProxy(QNetworkReply *reply, const QByteArray &responseBody, bool checkEncryption, const QByteArray &key = "", - const QByteArray &iv = "", const QByteArray &salt = ""); - void bypassProxy(const QString &endpoint, QNetworkReply *reply, std::function requestFunction, + bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &responseBody, bool checkEncryption, + const QByteArray &key = "", const QByteArray &iv = "", const QByteArray &salt = ""); + void bypassProxy(const QString &endpoint, std::function requestFunction, std::function &sslErrors)> replyProcessingFunction); int m_requestTimeoutMsecs; diff --git a/client/images/controls/news-unread.svg b/client/images/controls/news-unread.svg new file mode 100644 index 000000000..22a7b1a06 --- /dev/null +++ b/client/images/controls/news-unread.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/client/images/controls/news.svg b/client/images/controls/news.svg new file mode 100644 index 000000000..92eff99ef --- /dev/null +++ b/client/images/controls/news.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/client/images/controls/settings-news.svg b/client/images/controls/settings-news.svg new file mode 100644 index 000000000..39225d466 --- /dev/null +++ b/client/images/controls/settings-news.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/images/controls/unread-dot.svg b/client/images/controls/unread-dot.svg new file mode 100644 index 000000000..3ba4e1782 --- /dev/null +++ b/client/images/controls/unread-dot.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/client/resources.qrc b/client/resources.qrc index 4c15c6bb7..a293d4c69 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -35,6 +35,9 @@ images/controls/mail.svg images/controls/map-pin.svg images/controls/more-vertical.svg + images/controls/news.svg + images/controls/news-unread.svg + images/controls/unread-dot.svg images/controls/plus.svg images/controls/qr-code.svg images/controls/radio-button-inner-circle-pressed.png @@ -49,6 +52,7 @@ images/controls/server.svg images/controls/settings-2.svg images/controls/settings.svg + images/controls/settings-news.svg images/controls/share-2.svg images/controls/split-tunneling.svg images/controls/tag.svg @@ -212,6 +216,8 @@ ui/qml/Pages2/PageSettingsServerServices.qml ui/qml/Pages2/PageSettingsServersList.qml ui/qml/Pages2/PageSettingsSplitTunneling.qml + ui/qml/Pages2/PageSettingsNewsNotifications.qml + ui/qml/Pages2/PageSettingsNewsDetail.qml ui/qml/Pages2/PageProtocolAwgClientSettings.qml ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml diff --git a/client/settings.cpp b/client/settings.cpp index fb9c72c12..e705615ac 100644 --- a/client/settings.cpp +++ b/client/settings.cpp @@ -578,3 +578,13 @@ void Settings::setAllowedDnsServers(const QStringList &servers) { setValue("Conf/allowedDnsServers", servers); } + +QStringList Settings::readNewsIds() const +{ + return value("News/readIds").toStringList(); +} + +void Settings::setReadNewsIds(const QStringList &ids) +{ + setValue("News/readIds", ids); +} diff --git a/client/settings.h b/client/settings.h index 0a73e13f3..5ce4fc01c 100644 --- a/client/settings.h +++ b/client/settings.h @@ -236,6 +236,9 @@ public: QStringList allowedDnsServers() const; void setAllowedDnsServers(const QStringList &servers); + QStringList readNewsIds() const; + void setReadNewsIds(const QStringList &ids); + signals: void saveLogsChanged(bool enabled); void screenshotsEnabledChanged(bool enabled); @@ -251,7 +254,7 @@ private: mutable SecureQSettings m_settings; QString m_gatewayEndpoint; - bool m_isDevGatewayEnv = false; + bool m_isDevGatewayEnv = true; }; #endif // SETTINGS_H diff --git a/client/ui/controllers/api/apiNewsController.cpp b/client/ui/controllers/api/apiNewsController.cpp new file mode 100644 index 000000000..45afacb1f --- /dev/null +++ b/client/ui/controllers/api/apiNewsController.cpp @@ -0,0 +1,65 @@ +#include "apiNewsController.h" + +#include "core/api/apiUtils.h" +#include +#include + +namespace +{ + namespace configKey + { + constexpr char userCountryCode[] = "user_country_code"; + constexpr char serviceType[] = "service_type"; + } +} + +ApiNewsController::ApiNewsController(const QSharedPointer &newsModel, const std::shared_ptr &settings, + const QSharedPointer &serversModel, QObject *parent) + : QObject(parent), m_newsModel(newsModel), m_settings(settings), m_serversModel(serversModel) +{ +} + +void ApiNewsController::fetchNews() +{ + if (m_serversModel.isNull()) { + qWarning() << "ServersModel is null, skip fetchNews"; + return; + } + const auto stacks = m_serversModel->gatewayStacks(); + if (stacks.isEmpty()) { + qDebug() << "No Gateway stacks, skip fetchNews"; + return; + } + GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs, + m_settings->isStrictKillSwitchEnabled()); + QByteArray responseBody; + QJsonObject payload; + payload.insert("locale", m_settings->getAppLanguage().name().split("_").first()); + + const QJsonObject stacksJson = stacks.toJson(); + if (stacksJson.contains(configKey::userCountryCode)) { + payload.insert(configKey::userCountryCode, stacksJson.value(configKey::userCountryCode)); + } + if (stacksJson.contains(configKey::serviceType)) { + payload.insert(configKey::serviceType, stacksJson.value(configKey::serviceType)); + } + + ErrorCode errorCode = gatewayController.post(QString("%1v1/news"), payload, responseBody); + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(responseBody); + QJsonArray newsArray; + if (doc.isArray()) { + newsArray = doc.array(); + } else if (doc.isObject()) { + QJsonObject obj = doc.object(); + if (obj.value("news").isArray()) { + newsArray = obj.value("news").toArray(); + } + } + + m_newsModel->updateModel(newsArray); +} diff --git a/client/ui/controllers/api/apiNewsController.h b/client/ui/controllers/api/apiNewsController.h new file mode 100644 index 000000000..e830c6829 --- /dev/null +++ b/client/ui/controllers/api/apiNewsController.h @@ -0,0 +1,33 @@ +#ifndef APINEWSCONTROLLER_H +#define APINEWSCONTROLLER_H + +#include +#include +#include +#include + +#include "core/api/apiDefs.h" +#include "core/controllers/gatewayController.h" +#include "settings.h" +#include "ui/models/newsModel.h" +#include "ui/models/servers_model.h" + +class ApiNewsController : public QObject +{ + Q_OBJECT +public: + explicit ApiNewsController(const QSharedPointer &newsModel, const std::shared_ptr &settings, + const QSharedPointer &serversModel, QObject *parent = nullptr); + + Q_INVOKABLE void fetchNews(); + +signals: + void errorOccurred(ErrorCode errorCode); + +private: + QSharedPointer m_newsModel; + std::shared_ptr m_settings; + QSharedPointer m_serversModel; +}; + +#endif // APINEWSCONTROLLER_H diff --git a/client/ui/controllers/pageController.h b/client/ui/controllers/pageController.h index 12dc8c80e..529106343 100644 --- a/client/ui/controllers/pageController.h +++ b/client/ui/controllers/pageController.h @@ -26,6 +26,8 @@ namespace PageLoader PageSettingsConnection, PageSettingsDns, PageSettingsApplication, + PageSettingsNewsNotifications, + PageSettingsNewsDetail, PageSettingsBackup, PageSettingsAbout, PageSettingsLogging, diff --git a/client/ui/models/newsModel.cpp b/client/ui/models/newsModel.cpp new file mode 100644 index 000000000..408c1312a --- /dev/null +++ b/client/ui/models/newsModel.cpp @@ -0,0 +1,130 @@ +#include "ui/models/newsModel.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +NewsModel::NewsModel(const std::shared_ptr &settings, QObject *parent) : QAbstractListModel(parent), m_settings(settings) +{ + loadReadIds(); +} + +int NewsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return m_items.size(); +} + +QVariant NewsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return QVariant(); + + const NewsItem &item = m_items.at(index.row()); + switch (role) { + case IdRole: return item.id; + case TitleRole: return item.title; + case ContentRole: return item.content; + case TimestampRole: return item.timestamp.toString(Qt::ISODate); + case IsReadRole: return item.read; + case IsProcessedRole: return index.row() == m_processedIndex; + default: return QVariant(); + } +} + +QHash NewsModel::roleNames() const +{ + QHash roles; + roles[IdRole] = "id"; + roles[TitleRole] = "title"; + roles[ContentRole] = "content"; + roles[TimestampRole] = "timestamp"; + roles[IsReadRole] = "read"; + roles[IsProcessedRole] = "isProcessed"; + return roles; +} + +void NewsModel::markAsRead(int index) +{ + if (index < 0 || index >= m_items.size()) + return; + if (!m_items[index].read) { + m_items[index].read = true; + m_readIds.insert(m_items[index].id); + saveReadIds(); + QModelIndex idx = createIndex(index, 0); + emit dataChanged(idx, idx, { IsReadRole }); + emit hasUnreadChanged(); + } +} + +int NewsModel::processedIndex() const +{ + return m_processedIndex; +} + +void NewsModel::setProcessedIndex(int index) +{ + if (index < 0 || index >= m_items.size() || m_processedIndex == index) + return; + m_processedIndex = index; + emit processedIndexChanged(index); +} + +void NewsModel::updateModel(const QJsonArray &serverItems) +{ + QSet existingIds; + for (const NewsItem &item : m_items) { + existingIds.insert(item.id); + } + + QList newItems; + for (const QJsonValue &value : serverItems) { + if (!value.isObject()) + continue; + const QJsonObject obj = value.toObject(); + QString id = obj.value("id").toString(); + + if (!existingIds.contains(id)) { + NewsItem item; + item.id = id; + item.title = obj.value("title").toString(); + item.content = obj.value("content").toString(); + item.timestamp = QDateTime::fromString(obj.value("timestamp").toString(), Qt::ISODate); + item.read = m_readIds.contains(id); + newItems.append(item); + existingIds.insert(id); + } + } + + beginResetModel(); + m_items.append(newItems); + std::sort(m_items.begin(), m_items.end(), [](const NewsItem &a, const NewsItem &b) { return a.timestamp > b.timestamp; }); + endResetModel(); + emit hasUnreadChanged(); +} + +bool NewsModel::hasUnread() const +{ + for (const NewsItem &item : m_items) { + if (!item.read) + return true; + } + return false; +} + +void NewsModel::loadReadIds() +{ + QStringList ids = m_settings->readNewsIds(); + m_readIds = QSet(ids.begin(), ids.end()); +} + +void NewsModel::saveReadIds() const +{ + m_settings->setReadNewsIds(QStringList(m_readIds.begin(), m_readIds.end())); +} diff --git a/client/ui/models/newsModel.h b/client/ui/models/newsModel.h new file mode 100644 index 000000000..6188a9818 --- /dev/null +++ b/client/ui/models/newsModel.h @@ -0,0 +1,62 @@ +#ifndef NEWSMODEL_H +#define NEWSMODEL_H + +#include "settings.h" +#include +#include +#include +#include +#include +#include +#include + +struct NewsItem +{ + QString id; + QString title; + QString content; + QDateTime timestamp; + bool read; +}; + +class NewsModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles { + IdRole = Qt::UserRole + 1, + TitleRole, + ContentRole, + TimestampRole, + IsReadRole, + IsProcessedRole + }; + explicit NewsModel(const std::shared_ptr &settings, QObject *parent = nullptr); + Q_INVOKABLE void markAsRead(int index); + + Q_PROPERTY(int processedIndex READ processedIndex WRITE setProcessedIndex NOTIFY processedIndexChanged) + Q_PROPERTY(bool hasUnread READ hasUnread NOTIFY hasUnreadChanged) + int processedIndex() const; + void setProcessedIndex(int index); + + void updateModel(const QJsonArray &items); + bool hasUnread() const; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + +signals: + void processedIndexChanged(int index); + void hasUnreadChanged(); + +private: + QVector m_items; + int m_processedIndex = -1; + std::shared_ptr m_settings; + QSet m_readIds; + void loadReadIds(); + void saveReadIds() const; +}; + +#endif // NEWSMODEL_H diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index ebe6c857a..5d8910d00 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -44,6 +44,8 @@ ServersModel::ServersModel(std::shared_ptr settings, QObject *parent) connect(this, &ServersModel::processedServerIndexChanged, this, &ServersModel::processedServerChanged); connect(this, &ServersModel::dataChanged, this, &ServersModel::processedServerChanged); + + connect(this, &QAbstractItemModel::modelReset, this, &ServersModel::recomputeGatewayStacks); } int ServersModel::rowCount(const QModelIndex &parent) const @@ -375,7 +377,6 @@ QHash ServersModel::roleNames() const { QHash roles; - roles[NameRole] = "serverName"; roles[NameRole] = "name"; roles[ServerDescriptionRole] = "serverDescription"; roles[CollapsedServerDescriptionRole] = "collapsedServerDescription"; @@ -756,6 +757,68 @@ bool ServersModel::isServerFromApi(const int serverIndex) return data(serverIndex, IsServerFromTelegramApiRole).toBool() || data(serverIndex, IsServerFromGatewayApiRole).toBool(); } +bool ServersModel::hasServersFromGatewayApi() +{ + return !m_gatewayStacks.isEmpty(); +} + +bool ServersModel::GatewayStacks::operator==(const GatewayStacks &other) const +{ + return userCountryCodes == other.userCountryCodes && serviceTypes == other.serviceTypes; +} + +QJsonObject ServersModel::GatewayStacks::toJson() const +{ + QJsonObject obj; + if (!userCountryCodes.isEmpty()) { + obj.insert(configKey::userCountryCode, QJsonArray::fromStringList(userCountryCodes.values())); + } + if (!serviceTypes.isEmpty()) { + obj.insert(configKey::serviceType, QJsonArray::fromStringList(serviceTypes.values())); + } + return obj; +} + +void ServersModel::recomputeGatewayStacks() +{ + const bool wasEmpty = m_gatewayStacks.isEmpty(); + GatewayStacks computed; + bool hasNewTags = false; + + for (int i = 0; i < m_servers.count(); ++i) { + if (data(i, IsServerFromGatewayApiRole).toBool()) { + const QJsonObject server = m_servers.at(i).toObject(); + const QJsonObject apiConfig = server.value(configKey::apiConfig).toObject(); + + const QString userCountryCode = apiConfig.value(configKey::userCountryCode).toString(); + const QString serviceType = apiConfig.value(configKey::serviceType).toString(); + + if (!userCountryCode.isEmpty()) { + if (!m_gatewayStacks.userCountryCodes.contains(userCountryCode)) { + hasNewTags = true; + } + computed.userCountryCodes.insert(userCountryCode); + } + + if (!serviceType.isEmpty()) { + if (!m_gatewayStacks.serviceTypes.contains(serviceType)) { + hasNewTags = true; + } + computed.serviceTypes.insert(serviceType); + } + } + } + + m_gatewayStacks = std::move(computed); + if (hasNewTags) { + emit gatewayStacksExpanded(); + } + + if (wasEmpty != m_gatewayStacks.isEmpty()) { + emit hasServersFromGatewayApiChanged(); + } +} + bool ServersModel::isApiKeyExpired(const int serverIndex) { auto serverConfig = m_servers.at(serverIndex).toObject(); diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index c36b65346..973b54189 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -10,6 +10,16 @@ class ServersModel : public QAbstractListModel { Q_OBJECT public: + struct GatewayStacks + { + QSet userCountryCodes; + QSet serviceTypes; + + bool isEmpty() const { return userCountryCodes.isEmpty() && serviceTypes.isEmpty(); } + bool operator==(const GatewayStacks &other) const; + QJsonObject toJson() const; + }; + enum Roles { NameRole = Qt::UserRole + 1, ServerDescriptionRole, @@ -52,6 +62,8 @@ public: void resetModel(); + GatewayStacks gatewayStacks() const { return m_gatewayStacks; } + Q_PROPERTY(int defaultIndex READ getDefaultServerIndex WRITE setDefaultServerIndex NOTIFY defaultServerIndexChanged) Q_PROPERTY(QString defaultServerName READ getDefaultServerName NOTIFY defaultServerNameChanged) Q_PROPERTY(QString defaultServerDefaultContainerName READ getDefaultServerDefaultContainerName NOTIFY defaultServerDefaultContainerChanged) @@ -62,6 +74,8 @@ public: defaultServerDefaultContainerChanged) Q_PROPERTY(bool isDefaultServerFromApi READ isDefaultServerFromApi NOTIFY defaultServerIndexChanged) + Q_PROPERTY(bool hasServersFromGatewayApi READ hasServersFromGatewayApi NOTIFY hasServersFromGatewayApiChanged) + Q_PROPERTY(int processedIndex READ getProcessedServerIndex WRITE setProcessedServerIndex NOTIFY processedServerIndexChanged) Q_PROPERTY(bool processedServerIsPremium READ processedServerIsPremium NOTIFY processedServerChanged) @@ -82,6 +96,8 @@ public slots: bool isDefaultServerHasWriteAccess(); bool hasServerWithWriteAccess(); + bool hasServersFromGatewayApi(); + const int getServersCount(); void setProcessedServerIndex(const int index); @@ -147,6 +163,9 @@ signals: void updateApiCountryModel(); void updateApiServicesModel(); + void hasServersFromGatewayApiChanged(); + void gatewayStacksExpanded(); + private: ServerCredentials serverCredentials(int index) const; @@ -167,6 +186,9 @@ private: int m_processedServerIndex; bool m_isAmneziaDnsEnabled = m_settings->useAmneziaDns(); + + GatewayStacks m_gatewayStacks; + void recomputeGatewayStacks(); }; #endif // SERVERSMODEL_H diff --git a/client/ui/qml/Pages2/PageSettings.qml b/client/ui/qml/Pages2/PageSettings.qml index de8a00413..1b03c93d0 100644 --- a/client/ui/qml/Pages2/PageSettings.qml +++ b/client/ui/qml/Pages2/PageSettings.qml @@ -1,163 +1,177 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtQuick.Dialogs - -import PageEnum 1.0 -import Style 1.0 - -import "./" -import "../Controls2" -import "../Controls2/TextTypes" -import "../Config" - -PageType { - id: root - - ListViewType { - id: listView - - anchors.fill: parent - - header: ColumnLayout { - width: listView.width - - BaseHeaderType { - id: header - Layout.fillWidth: true - Layout.topMargin: 24 - Layout.bottomMargin: 16 - Layout.rightMargin: 16 - Layout.leftMargin: 16 - - headerText: qsTr("Settings") - } - } - - model: settingsEntries - - delegate: ColumnLayout { - width: listView.width - - spacing: 0 - - LabelWithButtonType { - Layout.fillWidth: true - - visible: isVisible - - text: title - rightImageSource: "qrc:/images/controls/chevron-right.svg" - leftImageSource: leftImagePath - - clickedFunction: clickedHandler - } - - DividerType { - visible: isVisible - } - } - - footer: ColumnLayout { - width: listView.width - - LabelWithButtonType { - id: close - - visible: GC.isDesktop() - Layout.fillWidth: true - - text: qsTr("Close application") - leftImageSource: "qrc:/images/controls/x-circle.svg" - isLeftImageHoverEnabled: false - - clickedFunction: function() { - PageController.closeApplication() - } - } - - DividerType { - Layout.fillWidth: true - Layout.leftMargin: 16 - Layout.rightMargin: 16 - - visible: GC.isDesktop() - } - } - } - - property list settingsEntries: [ - servers, - connection, - application, - backup, - about, - devConsole - ] - - QtObject { - id: servers - - property string title: qsTr("Servers") - readonly property string leftImagePath: "qrc:/images/controls/server.svg" - property bool isVisible: true - readonly property var clickedHandler: function() { - PageController.goToPage(PageEnum.PageSettingsServersList) - } - } - - QtObject { - id: connection - - property string title: qsTr("Connection") - readonly property string leftImagePath: "qrc:/images/controls/radio.svg" - property bool isVisible: true - readonly property var clickedHandler: function() { - PageController.goToPage(PageEnum.PageSettingsConnection) - } - } - - QtObject { - id: application - - property string title: qsTr("Application") - readonly property string leftImagePath: "qrc:/images/controls/app.svg" - property bool isVisible: true - readonly property var clickedHandler: function() { - PageController.goToPage(PageEnum.PageSettingsApplication) - } - } - - QtObject { - id: backup - - property string title: qsTr("Backup") - readonly property string leftImagePath: "qrc:/images/controls/save.svg" - property bool isVisible: true - readonly property var clickedHandler: function() { - PageController.goToPage(PageEnum.PageSettingsBackup) - } - } - - QtObject { - id: about - - property string title: qsTr("About AmneziaVPN") - readonly property string leftImagePath: "qrc:/images/controls/amnezia.svg" - property bool isVisible: true - readonly property var clickedHandler: function() { - PageController.goToPage(PageEnum.PageSettingsAbout) - } - } - - QtObject { - id: devConsole - - property string title: qsTr("Dev console") - readonly property string leftImagePath: "qrc:/images/controls/bug.svg" - property bool isVisible: SettingsController.isDevModeEnabled - readonly property var clickedHandler: function() { - PageController.goToPage(PageEnum.PageDevMenu) - } - } -} +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" + +PageType { + id: root + + ListViewType { + id: listView + + anchors.fill: parent + + header: ColumnLayout { + width: listView.width + + BaseHeaderType { + id: header + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.bottomMargin: 16 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + headerText: qsTr("Settings") + } + } + + model: settingsEntries + + delegate: ColumnLayout { + width: listView.width + + spacing: 0 + + LabelWithButtonType { + Layout.fillWidth: true + + visible: isVisible + + text: title + rightImageSource: "qrc:/images/controls/chevron-right.svg" + leftImageSource: leftImagePath + + clickedFunction: clickedHandler + } + + DividerType { + visible: isVisible + } + } + + footer: ColumnLayout { + width: listView.width + + LabelWithButtonType { + id: close + + visible: GC.isDesktop() + Layout.fillWidth: true + + text: qsTr("Close application") + leftImageSource: "qrc:/images/controls/x-circle.svg" + isLeftImageHoverEnabled: false + + clickedFunction: function() { + PageController.closeApplication() + } + } + + DividerType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + visible: GC.isDesktop() + } + } + } + + property list settingsEntries: [ + servers, + connection, + application, + news, + backup, + about, + devConsole + ] + + QtObject { + id: servers + + property string title: qsTr("Servers") + readonly property string leftImagePath: "qrc:/images/controls/server.svg" + property bool isVisible: true + readonly property var clickedHandler: function() { + PageController.goToPage(PageEnum.PageSettingsServersList) + } + } + + QtObject { + id: connection + + property string title: qsTr("Connection") + readonly property string leftImagePath: "qrc:/images/controls/radio.svg" + property bool isVisible: true + readonly property var clickedHandler: function() { + PageController.goToPage(PageEnum.PageSettingsConnection) + } + } + + QtObject { + id: application + + property string title: qsTr("Application") + readonly property string leftImagePath: "qrc:/images/controls/app.svg" + property bool isVisible: true + readonly property var clickedHandler: function() { + PageController.goToPage(PageEnum.PageSettingsApplication) + } + } + + QtObject { + id: news + + property string title: qsTr("News & Notifications") + readonly property string leftImagePath: NewsModel.hasUnread ? "qrc:/images/controls/news-unread.svg" : "qrc:/images/controls/news.svg" + property bool isVisible: ServersModel.hasServersFromGatewayApi + readonly property var clickedHandler: function() { + if (!ServersModel.hasServersFromGatewayApi) return; + ApiNewsController.fetchNews(); + PageController.goToPage(PageEnum.PageSettingsNewsNotifications) + } + } + + QtObject { + id: backup + + property string title: qsTr("Backup") + readonly property string leftImagePath: "qrc:/images/controls/save.svg" + property bool isVisible: true + readonly property var clickedHandler: function() { + PageController.goToPage(PageEnum.PageSettingsBackup) + } + } + + QtObject { + id: about + + property string title: qsTr("About AmneziaVPN") + readonly property string leftImagePath: "qrc:/images/controls/amnezia.svg" + property bool isVisible: true + readonly property var clickedHandler: function() { + PageController.goToPage(PageEnum.PageSettingsAbout) + } + } + + QtObject { + id: devConsole + + property string title: qsTr("Dev console") + readonly property string leftImagePath: "qrc:/images/controls/bug.svg" + property bool isVisible: SettingsController.isDevModeEnabled + readonly property var clickedHandler: function() { + PageController.goToPage(PageEnum.PageDevMenu) + } + } +} diff --git a/client/ui/qml/Pages2/PageSettingsNewsDetail.qml b/client/ui/qml/Pages2/PageSettingsNewsDetail.qml new file mode 100644 index 000000000..bda9543cc --- /dev/null +++ b/client/ui/qml/Pages2/PageSettingsNewsDetail.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import SortFilterProxyModel 0.2 + +PageType { + id: root + property var newsItem + + SortFilterProxyModel { + id: proxyNews + sourceModel: NewsModel + filters: [ ValueFilter { roleName: "isProcessed"; value: true } ] + Component.onCompleted: root.newsItem = proxyNews.get(0) + } + + Connections { + target: NewsModel + function onProcessedIndexChanged() { + root.newsItem = proxyNews.get(0) + } + } + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + } + + FlickableType { + id: fl + anchors.top: backButton.bottom + anchors.bottom: parent.bottom + contentHeight: content.height + + ColumnLayout { + id: content + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + headerText: newsItem.title + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: newsItem.content + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml b/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml new file mode 100644 index 000000000..cd4a1b008 --- /dev/null +++ b/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml @@ -0,0 +1,81 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" + +PageType { + id: root + + ColumnLayout { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + anchors.topMargin: 20 + + BackButtonType { + id: backButton + } + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + headerText: qsTr("News & Notifications") + } + } + + ListView { + id: newsList + width: parent.width + anchors.top: header.bottom + anchors.topMargin: 16 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + property bool isFocusable: true + + model: NewsModel + + clip: true + reuseItems: true + + delegate: Item { + implicitWidth: newsList.width + implicitHeight: content.implicitHeight + + ColumnLayout { + id: content + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + LabelWithButtonType { + Layout.fillWidth: true + leftImageSource: read ? "" : "qrc:/images/controls/unread-dot.svg" + isSmallLeftImage: !read + text: title + rightImageSource: "qrc:/images/controls/chevron-right.svg" + + clickedFunction: function() { + NewsModel.markAsRead(index) + NewsModel.processedIndex = index + PageController.goToPage(PageEnum.PageSettingsNewsDetail) + } + } + + DividerType {} + } + } + } +} diff --git a/client/ui/qml/Pages2/PageStart.qml b/client/ui/qml/Pages2/PageStart.qml index 570c51f30..65cc9a3da 100644 --- a/client/ui/qml/Pages2/PageStart.qml +++ b/client/ui/qml/Pages2/PageStart.qml @@ -380,7 +380,13 @@ PageType { objectName: "settingsTabButton" isSelected: tabBar.currentIndex === 2 - image: "qrc:/images/controls/settings.svg" + image: (ServersModel.hasServersFromGatewayApi && NewsModel.hasUnread) ? "qrc:/images/controls/settings-news.svg" : "qrc:/images/controls/settings.svg" + Binding { + target: settingsTabButton + property: "defaultColor" + value: "transparent" + when: (ServersModel.hasServersFromGatewayApi && NewsModel.hasUnread) + } clickedFunc: function () { tabBarStackView.goToTabBarPage(PageEnum.PageSettings) tabBar.currentIndex = 2