From f0f0f7c5bee5a53f16c121f31ba26c1c36ba69e3 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 24 Mar 2026 17:45:02 +0300 Subject: [PATCH] feat: add subscription renewal (#2389) * feat: add renewal subsribe * fix: after review --- client/core/api/apiUtils.cpp | 3 + client/core/controllers/coreController.cpp | 2 + client/resources.qrc | 1 + .../controllers/api/apiConfigsController.cpp | 6 +- .../ui/controllers/api/apiConfigsController.h | 1 + .../controllers/api/apiSettingsController.cpp | 37 ++++++ .../controllers/api/apiSettingsController.h | 2 + client/ui/models/api/apiAccountInfoModel.cpp | 25 ++++ client/ui/models/api/apiAccountInfoModel.h | 6 +- .../Components/SubscriptionExpiredDrawer.qml | 113 ++++++++++++++++++ .../qml/Pages2/PageSettingsApiServerInfo.qml | 62 ++++++++++ client/ui/qml/main2.qml | 28 +++++ 12 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 client/ui/qml/Components/SubscriptionExpiredDrawer.qml diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 2d16c384c..92d1e9854 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -96,6 +96,7 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl const int httpStatusCodeConflict = 409; const int httpStatusCodeNotFound = 404; const int httpStatusCodeNotImplemented = 501; + const int httpStatusCodeUnprocessableEntity = 422; if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; @@ -128,6 +129,8 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl return amnezia::ErrorCode::ApiNotFoundError; } else if (httpStatusFromBody == httpStatusCodeNotImplemented) { return amnezia::ErrorCode::ApiUpdateRequestError; + } else if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { + return amnezia::ErrorCode::ApiSubscriptionExpiredError; } return amnezia::ErrorCode::ApiConfigDownloadError; } diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index c8ea65e5d..390baf5a8 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -153,6 +153,8 @@ void CoreController::initControllers() m_apiConfigsController.reset(new ApiConfigsController(m_serversModel, m_apiServicesModel, m_settings)); m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get()); + connect(m_apiConfigsController.get(), &ApiConfigsController::subscriptionExpiredOnServer, + m_apiAccountInfoModel.get(), &ApiAccountInfoModel::setSubscriptionExpiredByServer); m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings, m_serversModel, this)); m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get()); diff --git a/client/resources.qrc b/client/resources.qrc index c050650eb..a1e4c656d 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -135,6 +135,7 @@ ui/qml/Components/InstalledAppsDrawer.qml ui/qml/Components/QuestionDrawer.qml ui/qml/Components/SelectLanguageDrawer.qml + ui/qml/Components/SubscriptionExpiredDrawer.qml ui/qml/Components/ServersListView.qml ui/qml/Components/SettingsContainersListView.qml ui/qml/Components/TransportProtoSelector.qml diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index c86342941..df0d16837 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -758,7 +758,11 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const } return true; } else { - emit errorOccurred(errorCode); + if (errorCode == ErrorCode::ApiSubscriptionExpiredError) { + emit subscriptionExpiredOnServer(); + } else { + emit errorOccurred(errorCode); + } return false; } } diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index dc6546426..8ca775b86 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -43,6 +43,7 @@ public slots: signals: void errorOccurred(ErrorCode errorCode); + void subscriptionExpiredOnServer(); void installServerFromApiFinished(const QString &message); void changeApiCountryFinished(const QString &message); diff --git a/client/ui/controllers/api/apiSettingsController.cpp b/client/ui/controllers/api/apiSettingsController.cpp index 59a68fd88..4e343a98b 100644 --- a/client/ui/controllers/api/apiSettingsController.cpp +++ b/client/ui/controllers/api/apiSettingsController.cpp @@ -1,6 +1,7 @@ #include "apiSettingsController.h" #include +#include #include #include "core/api/apiUtils.h" @@ -85,6 +86,42 @@ bool ApiSettingsController::getAccountInfo(bool reload) return true; } +void ApiSettingsController::getRenewalLink() +{ + auto processedIndex = m_serversModel->getProcessedServerIndex(); + auto serverConfig = m_serversModel->getServerConfig(processedIndex); + auto apiConfig = serverConfig.value(configKey::apiConfig).toObject(); + auto authData = serverConfig.value(configKey::authData).toObject(); + + bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false); + auto gatewayController = QSharedPointer::create(m_settings->getGatewayEndpoint(isTestPurchase), + m_settings->isDevGatewayEnv(isTestPurchase), + requestTimeoutMsecs, + m_settings->isStrictKillSwitchEnabled()); + + QJsonObject apiPayload; + apiPayload[configKey::userCountryCode] = apiConfig.value(configKey::userCountryCode).toString(); + apiPayload[configKey::serviceType] = apiConfig.value(configKey::serviceType).toString(); + apiPayload[configKey::authData] = authData; + apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION); + apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first(); + + auto future = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload); + future.then(this, [this, gatewayController](QPair result) { + auto [errorCode, responseBody] = result; + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return; + } + + QJsonObject responseJson = QJsonDocument::fromJson(responseBody).object(); + QString url = responseJson.value("url").toString(); + if (!url.isEmpty()) { + emit renewalLinkReceived(url); + } + }); +} + void ApiSettingsController::updateApiCountryModel() { m_apiCountryModel->updateModel(m_apiAccountInfoModel->getAvailableCountries(), ""); diff --git a/client/ui/controllers/api/apiSettingsController.h b/client/ui/controllers/api/apiSettingsController.h index afe9a5705..5853fbd87 100644 --- a/client/ui/controllers/api/apiSettingsController.h +++ b/client/ui/controllers/api/apiSettingsController.h @@ -21,9 +21,11 @@ public slots: bool getAccountInfo(bool reload); void updateApiCountryModel(); void updateApiDevicesModel(); + void getRenewalLink(); signals: void errorOccurred(ErrorCode errorCode); + void renewalLinkReceived(const QString &url); private: QSharedPointer m_serversModel; diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 65fc0083f..6c59fc907 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -1,5 +1,6 @@ #include "apiAccountInfoModel.h" +#include #include #include "core/api/apiUtils.h" @@ -75,6 +76,19 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const } return false; } + case IsSubscriptionExpiredRole: { + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) return false; + if (m_isSubscriptionExpiredByServer) return true; + if (m_accountInfoData.subscriptionEndDate.isEmpty()) return false; + return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate); + } + case IsSubscriptionExpiringSoonRole: { + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) return false; + if (m_accountInfoData.subscriptionEndDate.isEmpty()) return false; + if (apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate)) return false; + QDateTime endDate = QDateTime::fromString(m_accountInfoData.subscriptionEndDate, Qt::ISODateWithMs); + return endDate <= QDateTime::currentDateTimeUtc().addDays(30); + } } return QVariant(); @@ -84,6 +98,8 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons { beginResetModel(); + m_isSubscriptionExpiredByServer = false; + AccountInfoData accountInfoData; m_availableCountries = accountInfoObject.value(apiDefs::key::availableCountries).toArray(); @@ -108,6 +124,13 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons endResetModel(); } +void ApiAccountInfoModel::setSubscriptionExpiredByServer() +{ + beginResetModel(); + m_isSubscriptionExpiredByServer = true; + endResetModel(); +} + QVariant ApiAccountInfoModel::data(const QString &roleString) { QModelIndex modelIndex = index(0); @@ -166,6 +189,8 @@ QHash ApiAccountInfoModel::roleNames() const roles[IsComponentVisibleRole] = "isComponentVisible"; roles[HasExpiredWorkerRole] = "hasExpiredWorker"; roles[IsProtocolSelectionSupportedRole] = "isProtocolSelectionSupported"; + roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; + roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; return roles; } diff --git a/client/ui/models/api/apiAccountInfoModel.h b/client/ui/models/api/apiAccountInfoModel.h index 836bc8926..fb04079c6 100644 --- a/client/ui/models/api/apiAccountInfoModel.h +++ b/client/ui/models/api/apiAccountInfoModel.h @@ -19,7 +19,9 @@ public: EndDateRole, IsComponentVisibleRole, HasExpiredWorkerRole, - IsProtocolSelectionSupportedRole + IsProtocolSelectionSupportedRole, + IsSubscriptionExpiredRole, + IsSubscriptionExpiringSoonRole }; explicit ApiAccountInfoModel(QObject *parent = nullptr); @@ -31,6 +33,7 @@ public: public slots: void updateModel(const QJsonObject &accountInfoObject, const QJsonObject &serverConfig); QVariant data(const QString &roleString); + void setSubscriptionExpiredByServer(); QJsonArray getAvailableCountries(); QJsonArray getIssuedConfigsInfo(); @@ -59,6 +62,7 @@ private: }; AccountInfoData m_accountInfoData; + bool m_isSubscriptionExpiredByServer = false; QJsonArray m_availableCountries; QJsonArray m_issuedConfigsInfo; QJsonObject m_supportInfo; diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml new file mode 100644 index 000000000..2a1adceda --- /dev/null +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -0,0 +1,113 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import PageEnum 1.0 +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" + +DrawerType2 { + id: root + + expandedStateContent: ColumnLayout { + id: content + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + spacing: 0 + + onImplicitHeightChanged: { + root.expandedHeight = content.implicitHeight + 32 + SettingsController.safeAreaBottomMargin + } + + Item { + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + implicitHeight: titleText.implicitHeight + + Header2TextType { + id: titleText + anchors.left: parent.left + anchors.right: icon.left + anchors.rightMargin: 8 + + text: qsTr("Amnezia Premium subscription has expired") + horizontalAlignment: Text.AlignLeft + } + + Image { + id: icon + anchors.right: parent.right + anchors.top: parent.top + width: 40 + height: 40 + source: "qrc:/images/controls/history.svg" + fillMode: Image.PreserveAspectFit + visible: false + } + + ColorOverlay { + anchors.fill: icon + source: icon + color: AmneziaStyle.color.goldenApricot + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + text: qsTr("Renew your subscription to continue using VPN") + horizontalAlignment: Text.AlignLeft + } + + BasicButtonType { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + text: qsTr("Renew") + + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + ApiSettingsController.getRenewalLink() + } + } + + BasicButtonType { + Layout.fillWidth: true + Layout.topMargin: 4 + Layout.bottomMargin: 8 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.goldenApricot + + text: qsTr("Support") + + clickedFunc: function() { + root.closeTriggered() + PageController.goToPage(PageEnum.PageSettingsApiSupport) + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 532ab6a10..140b17f29 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -52,6 +52,26 @@ PageType { property var processedServer + property bool isSubscriptionExpired: false + property bool isSubscriptionExpiringSoon: false + + function updateSubscriptionState() { + root.isSubscriptionExpired = ApiAccountInfoModel.data("isSubscriptionExpired") + root.isSubscriptionExpiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon") + } + + Component.onCompleted: { + root.updateSubscriptionState() + } + + Connections { + target: ApiAccountInfoModel + + function onModelReset() { + root.updateSubscriptionState() + } + } + Connections { target: ServersModel @@ -114,6 +134,48 @@ PageType { serverNameEditDrawer.openTriggered() } } + + Text { + visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 4 + + text: root.isSubscriptionExpired + ? qsTr("Subscription expired") + : qsTr("Subscription expiring soon") + + color: root.isSubscriptionExpired + ? AmneziaStyle.color.vibrantRed + : AmneziaStyle.color.goldenApricot + + font.pixelSize: 14 + font.weight: Font.Medium + wrapMode: Text.WordWrap + } + + BasicButtonType { + visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + Layout.bottomMargin: 8 + + text: qsTr("Renew subscription") + + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + ApiSettingsController.getRenewalLink() + } + } } delegate: ColumnLayout { diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 89b2bb98c..147f90b8b 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -288,6 +288,34 @@ Window { } } + Item { + objectName: "subscriptionExpiredDrawerItem" + + anchors.fill: parent + + SubscriptionExpiredDrawer { + id: subscriptionExpiredDrawer + + anchors.fill: parent + } + } + + Connections { + target: ApiConfigsController + + function onSubscriptionExpiredOnServer() { + subscriptionExpiredDrawer.openTriggered() + } + } + + Connections { + target: ApiSettingsController + + function onRenewalLinkReceived(url) { + Qt.openUrlExternally(url) + } + } + Item { objectName: "busyIndicatorItem"