From b457ef9a3f7f1c74f4898a3acd9e1955f80110b3 Mon Sep 17 00:00:00 2001 From: Nethius Date: Tue, 13 May 2025 12:29:33 +0800 Subject: [PATCH] feature/premium v1 migration (#1569) * feature: premium v1 migration * chore: added stage for macos with new qt version * chore: downgrade qif version * chore: minor ui fixes --- .github/workflows/deploy.yml | 82 +++++++- .github/workflows/tag-deploy.yml | 2 + client/CMakeLists.txt | 3 + client/core/api/apiDefs.h | 11 + client/core/api/apiUtils.cpp | 68 +++++- client/core/api/apiUtils.h | 2 + client/core/controllers/coreController.cpp | 28 ++- client/core/controllers/coreController.h | 4 + client/core/defs.h | 1 + client/core/errorstrings.cpp | 1 + client/resources.qrc | 3 + client/settings.cpp | 10 + client/settings.h | 3 + .../api/apiPremV1MigrationController.cpp | 127 ++++++++++++ .../api/apiPremV1MigrationController.h | 50 +++++ client/ui/models/servers_model.cpp | 19 ++ client/ui/models/servers_model.h | 1 + .../Components/ApiPremV1MigrationDrawer.qml | 194 ++++++++++++++++++ .../qml/Components/ApiPremV1SubListDrawer.qml | 89 ++++++++ client/ui/qml/Components/OtpCodeDrawer.qml | 77 +++++++ client/ui/qml/Components/QuestionDrawer.qml | 20 +- client/ui/qml/Pages2/PageHome.qml | 30 +++ .../ui/qml/Pages2/PageSettingsServerData.qml | 18 ++ 23 files changed, 829 insertions(+), 14 deletions(-) create mode 100644 client/ui/controllers/api/apiPremV1MigrationController.cpp create mode 100644 client/ui/controllers/api/apiPremV1MigrationController.h create mode 100644 client/ui/qml/Components/ApiPremV1MigrationDrawer.qml create mode 100644 client/ui/qml/Components/ApiPremV1SubListDrawer.qml create mode 100644 client/ui/qml/Components/OtpCodeDrawer.qml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae8768a6..86779f33 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,8 @@ jobs: DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} steps: - name: 'Install Qt' @@ -90,6 +92,8 @@ jobs: DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} steps: - name: 'Get sources' @@ -156,6 +160,8 @@ jobs: DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} steps: - name: 'Setup xcode' @@ -243,7 +249,7 @@ jobs: # ------------------------------------------------------ - Build-MacOS: + Build-MacOS-old: runs-on: macos-latest env: @@ -255,6 +261,78 @@ jobs: DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} + + steps: + - name: 'Setup xcode' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4.0' + + - name: 'Install Qt' + uses: jurplel/install-qt-action@v3 + with: + version: ${{ env.QT_VERSION }} + host: 'mac' + target: 'desktop' + arch: 'clang_64' + modules: 'qtremoteobjects qt5compat qtshadertools' + dir: ${{ runner.temp }} + setup-python: 'true' + set-env: 'true' + extra: '--external 7z --base ${{ env.QT_MIRROR }}' + + - name: 'Install Qt Installer Framework ${{ env.QIF_VERSION }}' + run: | + mkdir -pv ${{ runner.temp }}/Qt/Tools/QtInstallerFramework + wget https://qt.amzsvc.com/tools/ifw/${{ env.QIF_VERSION }}.zip + unzip ${{ env.QIF_VERSION }}.zip -d ${{ runner.temp }}/Qt/Tools/QtInstallerFramework/ + + - name: 'Get sources' + uses: actions/checkout@v4 + with: + submodules: 'true' + fetch-depth: 10 + + - name: 'Setup ccache' + uses: hendrikmuhs/ccache-action@v1.2 + + - name: 'Build project' + run: | + export QT_BIN_DIR="${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/macos/bin" + export QIF_BIN_DIR="${{ runner.temp }}/Qt/Tools/QtInstallerFramework/${{ env.QIF_VERSION }}/bin" + bash deploy/build_macos.sh + + - name: 'Upload installer artifact' + uses: actions/upload-artifact@v4 + with: + name: AmneziaVPN_MacOS_old_installer + path: AmneziaVPN.dmg + retention-days: 7 + + - name: 'Upload unpacked artifact' + uses: actions/upload-artifact@v4 + with: + name: AmneziaVPN_MacOS_old_unpacked + path: deploy/build/client/AmneziaVPN.app + retention-days: 7 + +# ------------------------------------------------------ + + Build-MacOS: + runs-on: macos-latest + + env: + QT_VERSION: 6.8.0 + QIF_VERSION: 4.8.1 + PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} + PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} + DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} + DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} steps: - name: 'Setup xcode' @@ -324,6 +402,8 @@ jobs: DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} steps: - name: 'Install desktop Qt' diff --git a/.github/workflows/tag-deploy.yml b/.github/workflows/tag-deploy.yml index 2bcbd8c6..31c334bf 100644 --- a/.github/workflows/tag-deploy.yml +++ b/.github/workflows/tag-deploy.yml @@ -20,6 +20,8 @@ jobs: DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} steps: - name: 'Install desktop Qt' diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index b3f775a0..a454142d 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -31,6 +31,9 @@ add_definitions(-DDEV_AGW_PUBLIC_KEY="$ENV{DEV_AGW_PUBLIC_KEY}") add_definitions(-DDEV_AGW_ENDPOINT="$ENV{DEV_AGW_ENDPOINT}") add_definitions(-DDEV_S3_ENDPOINT="$ENV{DEV_S3_ENDPOINT}") +add_definitions(-DFREE_V2_ENDPOINT="$ENV{FREE_V2_ENDPOINT}") +add_definitions(-DPREM_V1_ENDPOINT="$ENV{PREM_V1_ENDPOINT}") + if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID)) set(PACKAGES ${PACKAGES} Widgets) endif() diff --git a/client/core/api/apiDefs.h b/client/core/api/apiDefs.h index d1a92d9d..6d1a27fa 100644 --- a/client/core/api/apiDefs.h +++ b/client/core/api/apiDefs.h @@ -22,12 +22,19 @@ namespace apiDefs namespace key { constexpr QLatin1String configVersion("config_version"); + constexpr QLatin1String apiEndpoint("api_endpoint"); + constexpr QLatin1String apiKey("api_key"); + constexpr QLatin1String description("description"); + constexpr QLatin1String name("name"); + constexpr QLatin1String protocol("protocol"); constexpr QLatin1String apiConfig("api_config"); constexpr QLatin1String stackType("stack_type"); constexpr QLatin1String serviceType("service_type"); constexpr QLatin1String vpnKey("vpn_key"); + constexpr QLatin1String config("config"); + constexpr QLatin1String configs("configs"); constexpr QLatin1String installationUuid("installation_uuid"); constexpr QLatin1String workerLastUpdated("worker_last_updated"); @@ -51,6 +58,10 @@ namespace apiDefs constexpr QLatin1String website("website"); constexpr QLatin1String websiteName("website_name"); constexpr QLatin1String telegram("telegram"); + + constexpr QLatin1String id("id"); + constexpr QLatin1String orderId("order_id"); + constexpr QLatin1String migrationCode("migration_code"); } const int requestTimeoutMsecs = 12 * 1000; // 12 secs diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index f5f575c5..f85d2207 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -3,6 +3,24 @@ #include #include +namespace +{ + const QByteArray AMNEZIA_CONFIG_SIGNATURE = QByteArray::fromHex("000000ff"); + + QString escapeUnicode(const QString &input) + { + QString output; + for (QChar c : input) { + if (c.unicode() < 0x20 || c.unicode() > 0x7E) { + output += QString("\\u%1").arg(QString::number(c.unicode(), 16).rightJustified(4, '0')); + } else { + output += c; + } + } + return output; + } +} + bool apiUtils::isSubscriptionExpired(const QString &subscriptionEndDate) { QDateTime now = QDateTime::currentDateTime(); @@ -27,22 +45,28 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec case apiDefs::ConfigSource::Telegram: { }; case apiDefs::ConfigSource::AmneziaGateway: { - constexpr QLatin1String stackPremium("prem"); - constexpr QLatin1String stackFree("free"); - constexpr QLatin1String servicePremium("amnezia-premium"); constexpr QLatin1String serviceFree("amnezia-free"); constexpr QLatin1String serviceExternalPremium("external-premium"); + constexpr QLatin1String freeV2Endpoint(FREE_V2_ENDPOINT); + constexpr QLatin1String premiumV1Endpoint(PREM_V1_ENDPOINT); + auto apiConfigObject = serverConfigObject.value(apiDefs::key::apiConfig).toObject(); auto serviceType = apiConfigObject.value(apiDefs::key::serviceType).toString(); + auto apiEndpoint = serverConfigObject.value(apiDefs::key::apiEndpoint).toString(); + if (serviceType == servicePremium) { return apiDefs::ConfigType::AmneziaPremiumV2; } else if (serviceType == serviceFree) { return apiDefs::ConfigType::AmneziaFreeV3; } else if (serviceType == serviceExternalPremium) { return apiDefs::ConfigType::ExternalPremium; + } else if (apiEndpoint.contains(premiumV1Endpoint)) { + return apiDefs::ConfigType::AmneziaPremiumV1; + } else if (apiEndpoint.contains(freeV2Endpoint)) { + return apiDefs::ConfigType::AmneziaFreeV2; } } default: { @@ -95,3 +119,41 @@ bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject) apiDefs::ConfigType::ExternalPremium }; return premiumTypes.contains(getConfigType(serverConfigObject)); } + +QString apiUtils::getPremiumV1VpnKey(const QJsonObject &serverConfigObject) +{ + if (apiUtils::getConfigType(serverConfigObject) != apiDefs::ConfigType::AmneziaPremiumV1) { + return {}; + } + + QList> orderedFields; + orderedFields.append(qMakePair(apiDefs::key::name, serverConfigObject[apiDefs::key::name].toString())); + orderedFields.append(qMakePair(apiDefs::key::description, serverConfigObject[apiDefs::key::description].toString())); + orderedFields.append(qMakePair(apiDefs::key::configVersion, serverConfigObject[apiDefs::key::configVersion].toDouble())); + orderedFields.append(qMakePair(apiDefs::key::protocol, serverConfigObject[apiDefs::key::protocol].toString())); + orderedFields.append(qMakePair(apiDefs::key::apiEndpoint, serverConfigObject[apiDefs::key::apiEndpoint].toString())); + orderedFields.append(qMakePair(apiDefs::key::apiKey, serverConfigObject[apiDefs::key::apiKey].toString())); + + QString vpnKeyStr = "{"; + for (int i = 0; i < orderedFields.size(); ++i) { + const auto &pair = orderedFields[i]; + if (pair.second.typeId() == QMetaType::Type::QString) { + vpnKeyStr += "\"" + pair.first + "\": \"" + pair.second.toString() + "\""; + } else if (pair.second.typeId() == QMetaType::Type::Double || pair.second.typeId() == QMetaType::Type::Int) { + vpnKeyStr += "\"" + pair.first + "\": " + QString::number(pair.second.toDouble(), 'f', 1); + } + + if (i < orderedFields.size() - 1) { + vpnKeyStr += ", "; + } + } + vpnKeyStr += "}"; + + QByteArray vpnKeyCompressed = escapeUnicode(vpnKeyStr).toUtf8(); + vpnKeyCompressed = qCompress(vpnKeyCompressed, 6); + vpnKeyCompressed = vpnKeyCompressed.mid(4); + + QByteArray signedData = AMNEZIA_CONFIG_SIGNATURE + vpnKeyCompressed; + + return QString("vpn://%1").arg(QString(signedData.toBase64(QByteArray::Base64UrlEncoding))); +} diff --git a/client/core/api/apiUtils.h b/client/core/api/apiUtils.h index 47006e80..45eaf2de 100644 --- a/client/core/api/apiUtils.h +++ b/client/core/api/apiUtils.h @@ -19,6 +19,8 @@ namespace apiUtils apiDefs::ConfigSource getConfigSource(const QJsonObject &serverConfigObject); amnezia::ErrorCode checkNetworkReplyErrors(const QList &sslErrors, QNetworkReply *reply); + + QString getPremiumV1VpnKey(const QJsonObject &serverConfigObject); } #endif // APIUTILS_H diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index f2c09d45..0e72ef1a 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -148,6 +148,9 @@ void CoreController::initControllers() m_apiConfigsController.reset(new ApiConfigsController(m_serversModel, m_apiServicesModel, m_settings)); m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get()); + + m_apiPremV1MigrationController.reset(new ApiPremV1MigrationController(m_serversModel, m_settings, this)); + m_engine->rootContext()->setContextProperty("ApiPremV1MigrationController", m_apiPremV1MigrationController.get()); } void CoreController::initAndroidController() @@ -220,6 +223,8 @@ void CoreController::initSignalHandlers() initAutoConnectHandler(); initAmneziaDnsToggledHandler(); initPrepareConfigHandler(); + initImportPremiumV2VpnKeyHandler(); + initShowMigrationDrawerHandler(); initStrictKillSwitchHandler(); } @@ -363,10 +368,29 @@ void CoreController::initPrepareConfigHandler() }); } +void CoreController::initImportPremiumV2VpnKeyHandler() +{ + connect(m_apiPremV1MigrationController.get(), &ApiPremV1MigrationController::importPremiumV2VpnKey, this, [this](const QString &vpnKey) { + m_importController->extractConfigFromData(vpnKey); + m_importController->importConfig(); + + emit m_apiPremV1MigrationController->migrationFinished(); + }); +} + +void CoreController::initShowMigrationDrawerHandler() +{ + QTimer::singleShot(1000, this, [this]() { + if (m_apiPremV1MigrationController->isPremV1MigrationReminderActive() && m_apiPremV1MigrationController->hasConfigsToMigration()) { + m_apiPremV1MigrationController->showMigrationDrawer(); + } + }); +} + void CoreController::initStrictKillSwitchHandler() { - connect(m_settingsController.get(), &SettingsController::strictKillSwitchEnabledChanged, - m_vpnConnection.get(), &VpnConnection::onKillSwitchModeChanged); + connect(m_settingsController.get(), &SettingsController::strictKillSwitchEnabledChanged, m_vpnConnection.get(), + &VpnConnection::onKillSwitchModeChanged); } QSharedPointer CoreController::pageController() const diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 6342d738..9ae53562 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -7,6 +7,7 @@ #include "ui/controllers/api/apiConfigsController.h" #include "ui/controllers/api/apiSettingsController.h" +#include "ui/controllers/api/apiPremV1MigrationController.h" #include "ui/controllers/appSplitTunnelingController.h" #include "ui/controllers/allowedDnsController.h" #include "ui/controllers/connectionController.h" @@ -82,6 +83,8 @@ private: void initAutoConnectHandler(); void initAmneziaDnsToggledHandler(); void initPrepareConfigHandler(); + void initImportPremiumV2VpnKeyHandler(); + void initShowMigrationDrawerHandler(); void initStrictKillSwitchHandler(); QQmlApplicationEngine *m_engine {}; // TODO use parent child system here? @@ -109,6 +112,7 @@ private: QScopedPointer m_apiSettingsController; QScopedPointer m_apiConfigsController; + QScopedPointer m_apiPremV1MigrationController; QSharedPointer m_containersModel; QSharedPointer m_defaultServerContainersModel; diff --git a/client/core/defs.h b/client/core/defs.h index eff3df3b..674d1add 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -117,6 +117,7 @@ namespace amnezia ApiServicesMissingError = 1107, ApiConfigLimitError = 1108, ApiNotFoundError = 1109, + ApiMigrationError = 1110, // QFile errors OpenError = 1200, diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 6abab0e0..f330bc34 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -74,6 +74,7 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiServicesMissingError): errorMessage = QObject::tr("Missing list of available services"); break; case (ErrorCode::ApiConfigLimitError): errorMessage = QObject::tr("The limit of allowed configurations per subscription has been exceeded"); break; case (ErrorCode::ApiNotFoundError): errorMessage = QObject::tr("Error when retrieving configuration from API"); break; + case (ErrorCode::ApiMigrationError): errorMessage = QObject::tr("A migration error occurred. Please contact our technical support"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/resources.qrc b/client/resources.qrc index a36b60d1..72eb15c7 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -236,6 +236,9 @@ ui/qml/Pages2/PageSettingsApiNativeConfigs.qml ui/qml/Pages2/PageSettingsApiDevices.qml images/controls/monitor.svg + ui/qml/Components/ApiPremV1MigrationDrawer.qml + ui/qml/Components/ApiPremV1SubListDrawer.qml + ui/qml/Components/OtpCodeDrawer.qml images/flagKit/ZW.svg diff --git a/client/settings.cpp b/client/settings.cpp index 9a0a32e5..fb9c72c1 100644 --- a/client/settings.cpp +++ b/client/settings.cpp @@ -559,6 +559,16 @@ void Settings::disableHomeAdLabel() setValue("Conf/homeAdLabelVisible", false); } +bool Settings::isPremV1MigrationReminderActive() +{ + return value("Conf/premV1MigrationReminderActive", true).toBool(); +} + +void Settings::disablePremV1MigrationReminder() +{ + setValue("Conf/premV1MigrationReminderActive", false); +} + QStringList Settings::allowedDnsServers() const { return value("Conf/allowedDnsServers").toStringList(); diff --git a/client/settings.h b/client/settings.h index 01155c0c..eec6cc44 100644 --- a/client/settings.h +++ b/client/settings.h @@ -229,6 +229,9 @@ public: bool isHomeAdLabelVisible(); void disableHomeAdLabel(); + bool isPremV1MigrationReminderActive(); + void disablePremV1MigrationReminder(); + QStringList allowedDnsServers() const; void setAllowedDnsServers(const QStringList &servers); diff --git a/client/ui/controllers/api/apiPremV1MigrationController.cpp b/client/ui/controllers/api/apiPremV1MigrationController.cpp new file mode 100644 index 00000000..0a9b6139 --- /dev/null +++ b/client/ui/controllers/api/apiPremV1MigrationController.cpp @@ -0,0 +1,127 @@ +#include "apiPremV1MigrationController.h" + +#include +#include + +#include "core/api/apiDefs.h" +#include "core/api/apiUtils.h" +#include "core/controllers/gatewayController.h" + +ApiPremV1MigrationController::ApiPremV1MigrationController(const QSharedPointer &serversModel, + const std::shared_ptr &settings, QObject *parent) + : QObject(parent), m_serversModel(serversModel), m_settings(settings) +{ +} + +bool ApiPremV1MigrationController::hasConfigsToMigration() +{ + QJsonArray vpnKeys; + + auto serversCount = m_serversModel->getServersCount(); + for (size_t i = 0; i < serversCount; i++) { + auto serverConfigObject = m_serversModel->getServerConfig(i); + + if (apiUtils::getConfigType(serverConfigObject) != apiDefs::ConfigType::AmneziaPremiumV1) { + continue; + } + + QString vpnKey = apiUtils::getPremiumV1VpnKey(serverConfigObject); + vpnKeys.append(vpnKey); + } + + GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs); + QJsonObject apiPayload; + + apiPayload["configs"] = vpnKeys; + QByteArray responseBody; + ErrorCode errorCode = gatewayController.post(QString("%1v1/prem-v1/is-active-subscription"), apiPayload, responseBody); + + auto migrationsStatus = QJsonDocument::fromJson(responseBody).object(); + for (const auto &migrationStatus : migrationsStatus) { + if (migrationStatus == "not_found") { + return true; + } + } + + return false; +} + +void ApiPremV1MigrationController::getSubscriptionList(const QString &email) +{ + GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs); + QJsonObject apiPayload; + + apiPayload[apiDefs::key::email] = email; + QByteArray responseBody; + ErrorCode errorCode = gatewayController.post(QString("%1v1/prem-v1/subscription-list"), apiPayload, responseBody); + + if (errorCode == ErrorCode::NoError) { + m_email = email; + m_subscriptionsModel = QJsonDocument::fromJson(responseBody).array(); + if (m_subscriptionsModel.isEmpty()) { + emit noSubscriptionToMigrate(); + return; + } + + emit subscriptionsModelChanged(); + } else { + emit errorOccurred(ErrorCode::ApiMigrationError); + } +} + +QJsonArray ApiPremV1MigrationController::getSubscriptionModel() +{ + return m_subscriptionsModel; +} + +void ApiPremV1MigrationController::sendMigrationCode(const int subscriptionIndex) +{ + QEventLoop wait; + QTimer::singleShot(1000, &wait, &QEventLoop::quit); + wait.exec(); + + GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs); + QJsonObject apiPayload; + + apiPayload[apiDefs::key::email] = m_email; + QByteArray responseBody; + ErrorCode errorCode = gatewayController.post(QString("%1v1/prem-v1/migration-code"), apiPayload, responseBody); + + if (errorCode == ErrorCode::NoError) { + m_subscriptionIndex = subscriptionIndex; + emit otpSuccessfullySent(); + } else { + emit errorOccurred(ErrorCode::ApiMigrationError); + } +} + +void ApiPremV1MigrationController::migrate(const QString &migrationCode) +{ + GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs); + QJsonObject apiPayload; + + apiPayload[apiDefs::key::email] = m_email; + apiPayload[apiDefs::key::orderId] = m_subscriptionsModel.at(m_subscriptionIndex).toObject().value(apiDefs::key::id).toString(); + apiPayload[apiDefs::key::migrationCode] = migrationCode; + QByteArray responseBody; + ErrorCode errorCode = gatewayController.post(QString("%1v1/prem-v1/migrate"), apiPayload, responseBody); + + if (errorCode == ErrorCode::NoError) { + auto responseObject = QJsonDocument::fromJson(responseBody).object(); + QString premiumV2VpnKey = responseObject.value(apiDefs::key::config).toString(); + + emit importPremiumV2VpnKey(premiumV2VpnKey); + } else { + emit errorOccurred(ErrorCode::ApiMigrationError); + } +} + +bool ApiPremV1MigrationController::isPremV1MigrationReminderActive() +{ + return m_settings->isPremV1MigrationReminderActive(); +} + +void ApiPremV1MigrationController::disablePremV1MigrationReminder() +{ + m_settings->disablePremV1MigrationReminder(); +} diff --git a/client/ui/controllers/api/apiPremV1MigrationController.h b/client/ui/controllers/api/apiPremV1MigrationController.h new file mode 100644 index 00000000..d7c10460 --- /dev/null +++ b/client/ui/controllers/api/apiPremV1MigrationController.h @@ -0,0 +1,50 @@ +#ifndef APIPREMV1MIGRATIONCONTROLLER_H +#define APIPREMV1MIGRATIONCONTROLLER_H + +#include + +#include "ui/models/servers_model.h" + +class ApiPremV1MigrationController : public QObject +{ + Q_OBJECT +public: + ApiPremV1MigrationController(const QSharedPointer &serversModel, const std::shared_ptr &settings, + QObject *parent = nullptr); + + Q_PROPERTY(QJsonArray subscriptionsModel READ getSubscriptionModel NOTIFY subscriptionsModelChanged) + +public slots: + bool hasConfigsToMigration(); + void getSubscriptionList(const QString &email); + QJsonArray getSubscriptionModel(); + void sendMigrationCode(const int subscriptionIndex); + void migrate(const QString &migrationCode); + + bool isPremV1MigrationReminderActive(); + void disablePremV1MigrationReminder(); + +signals: + void subscriptionsModelChanged(); + + void otpSuccessfullySent(); + + void importPremiumV2VpnKey(const QString &vpnKey); + + void errorOccurred(ErrorCode errorCode); + + void showMigrationDrawer(); + void migrationFinished(); + + void noSubscriptionToMigrate(); + +private: + QSharedPointer m_serversModel; + std::shared_ptr m_settings; + + QJsonArray m_subscriptionsModel; + int m_subscriptionIndex; + QString m_email; +}; + +#endif // APIPREMV1MIGRATIONCONTROLLER_H diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index 7cde28b4..0a7b2526 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -348,6 +348,25 @@ void ServersModel::removeServer() endResetModel(); } +void ServersModel::removeServer(const int serverIndex) +{ + beginResetModel(); + m_settings->removeServer(serverIndex); + m_servers = m_settings->serversArray(); + + if (m_settings->defaultServerIndex() == serverIndex) { + setDefaultServerIndex(0); + } else if (m_settings->defaultServerIndex() > serverIndex) { + setDefaultServerIndex(m_settings->defaultServerIndex() - 1); + } + + if (m_settings->serversCount() == 0) { + setDefaultServerIndex(-1); + } + setProcessedServerIndex(m_defaultServerIndex); + endResetModel(); +} + QHash ServersModel::roleNames() const { QHash roles; diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index 4b790c7a..c4803708 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -90,6 +90,7 @@ public slots: void addServer(const QJsonObject &server); void editServer(const QJsonObject &server, const int serverIndex); void removeServer(); + void removeServer(const int serverIndex); QJsonObject getServerConfig(const int serverIndex); diff --git a/client/ui/qml/Components/ApiPremV1MigrationDrawer.qml b/client/ui/qml/Components/ApiPremV1MigrationDrawer.qml new file mode 100644 index 00000000..113ec1f6 --- /dev/null +++ b/client/ui/qml/Components/ApiPremV1MigrationDrawer.qml @@ -0,0 +1,194 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import QtCore + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +DrawerType2 { + id: root + + expandedHeight: parent.height * 0.9 + + Connections { + target: ApiPremV1MigrationController + + function onErrorOccurred(error, goToPageHome) { + PageController.showErrorMessage(error) + root.closeTriggered() + } + } + + expandedStateContent: Item { + implicitHeight: root.expandedHeight + + ListViewType { + id: listView + + anchors.fill: parent + + model: 1 // fake model to force the ListView to be created without a model + snapMode: ListView.NoSnap + + header: ColumnLayout { + width: listView.width + + Header2Type { + id: header + Layout.fillWidth: true + Layout.topMargin: 20 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + headerText: qsTr("Switch to the new Amnezia Premium subscription") + } + } + + delegate: ColumnLayout { + width: listView.width + + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + ParagraphTextType { + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.bottomMargin: 24 + + horizontalAlignment: Text.AlignLeft + textFormat: Text.RichText + text: { + var str = qsTr("We'll preserve all remaining days of your current subscription and give you an extra month as a thank you. ") + str += qsTr("This new subscription type will be actively developed with more locations and features added regularly. Currently available:") + str += "
    " + str += qsTr("
  • 9 locations (with more coming soon)
  • ") + str += qsTr("
  • Easier switching between countries in the app
  • ") + str += qsTr("
  • Personal dashboard to manage your subscription
  • ") + str += "
" + str += qsTr("Old keys will be deactivated after switching.") + } + } + + TextFieldWithHeaderType { + id: emailLabel + Layout.fillWidth: true + + borderColor: AmneziaStyle.color.mutedGray + headerTextColor: AmneziaStyle.color.paleGray + + headerText: qsTr("Email") + textField.placeholderText: qsTr("mail@example.com") + + + textField.onFocusChanged: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + } + + Connections { + target: ApiPremV1MigrationController + + function onNoSubscriptionToMigrate() { + emailLabel.errorText = qsTr("No old format subscriptions for a given email") + } + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.topMargin: 16 + + color: AmneziaStyle.color.mutedGray + + text: qsTr("Enter the email you used for your current subscription") + } + + ApiPremV1SubListDrawer { + id: apiPremV1SubListDrawer + parent: root + + anchors.fill: parent + } + + OtpCodeDrawer { + id: otpCodeDrawer + parent: root + + anchors.fill: parent + } + + BasicButtonType { + id: yesButton + Layout.fillWidth: true + Layout.topMargin: 32 + + text: qsTr("Continue") + + clickedFunc: function() { + PageController.showBusyIndicator(true) + ApiPremV1MigrationController.getSubscriptionList(emailLabel.textField.text) + PageController.showBusyIndicator(false) + } + } + + BasicButtonType { + id: noButton + Layout.fillWidth: true + + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + disabledColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.paleGray + borderWidth: 1 + + text: qsTr("Remind me later") + + clickedFunc: function() { + root.closeTriggered() + } + } + + BasicButtonType { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 32 + Layout.bottomMargin: 32 + implicitHeight: 32 + + defaultColor: "transparent" + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.vibrantRed + + text: qsTr("Don't remind me again") + + clickedFunc: function() { + var headerText = qsTr("No more reminders? You can always switch to the new format in the server settings") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + + var yesButtonFunction = function() { + ApiPremV1MigrationController.disablePremV1MigrationReminder() + root.closeTriggered() + } + var noButtonFunction = function() { + } + + showQuestionDrawer(headerText, "", yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } + } + } + } +} diff --git a/client/ui/qml/Components/ApiPremV1SubListDrawer.qml b/client/ui/qml/Components/ApiPremV1SubListDrawer.qml new file mode 100644 index 00000000..221b7a89 --- /dev/null +++ b/client/ui/qml/Components/ApiPremV1SubListDrawer.qml @@ -0,0 +1,89 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" + +DrawerType2 { + id: root + + Connections { + target: ApiPremV1MigrationController + + function onSubscriptionsModelChanged() { + if (ApiPremV1MigrationController.subscriptionsModel.length > 1) { + root.openTriggered() + } else { + sendMigrationCode(0) + } + } + } + + function sendMigrationCode(index) { + PageController.showBusyIndicator(true) + ApiPremV1MigrationController.sendMigrationCode(index) + root.closeTriggered() + PageController.showBusyIndicator(false) + } + + expandedHeight: parent.height * 0.9 + + expandedStateContent: Item { + implicitHeight: root.expandedHeight + + ListViewType { + id: listView + + anchors.fill: parent + + model: ApiPremV1MigrationController.subscriptionsModel + + header: ColumnLayout { + width: listView.width + + Header2Type { + id: header + Layout.fillWidth: true + Layout.topMargin: 20 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + headerText: qsTr("Choose Subscription") + } + } + + delegate: Item { + implicitWidth: listView.width + implicitHeight: delegateContent.implicitHeight + + ColumnLayout { + id: delegateContent + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + LabelWithButtonType { + id: server + Layout.fillWidth: true + + text: qsTr("Order ID: ") + modelData.id + + descriptionText: qsTr("Purchase Date: ") + Qt.formatDateTime(new Date(modelData.created_at), "dd.MM.yyyy hh:mm") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + + clickedFunction: function() { + sendMigrationCode(index) + } + } + + DividerType {} + } + } + } + } +} diff --git a/client/ui/qml/Components/OtpCodeDrawer.qml b/client/ui/qml/Components/OtpCodeDrawer.qml new file mode 100644 index 00000000..e26982b9 --- /dev/null +++ b/client/ui/qml/Components/OtpCodeDrawer.qml @@ -0,0 +1,77 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" + +import "../Config" + +DrawerType2 { + id: root + + Connections { + target: ApiPremV1MigrationController + + function onOtpSuccessfullySent() { + root.openTriggered() + } + } + + expandedHeight: parent.height * 0.6 + + expandedStateContent: Item { + implicitHeight: root.expandedHeight + + ColumnLayout { + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 0 + + Header2Type { + id: header + Layout.fillWidth: true + Layout.topMargin: 20 + + headerText: qsTr("OTP code was sent to your email") + } + + TextFieldWithHeaderType { + id: otpFiled + + borderColor: AmneziaStyle.color.mutedGray + headerTextColor: AmneziaStyle.color.paleGray + + Layout.fillWidth: true + Layout.topMargin: 16 + headerText: qsTr("OTP Code") + textField.maximumLength: 30 + checkEmptyText: true + } + + BasicButtonType { + id: saveButton + + Layout.fillWidth: true + Layout.topMargin: 16 + + text: qsTr("Continue") + + clickedFunc: function() { + PageController.showBusyIndicator(true) + ApiPremV1MigrationController.migrate(otpFiled.textField.text) + PageController.showBusyIndicator(false) + root.closeTriggered() + } + } + } + } +} diff --git a/client/ui/qml/Components/QuestionDrawer.qml b/client/ui/qml/Components/QuestionDrawer.qml index 0c14e52d..ddf5cda4 100644 --- a/client/ui/qml/Components/QuestionDrawer.qml +++ b/client/ui/qml/Components/QuestionDrawer.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -39,7 +41,7 @@ DrawerType2 { Layout.rightMargin: 16 Layout.leftMargin: 16 - text: headerText + text: root.headerText } ParagraphTextType { @@ -48,7 +50,7 @@ DrawerType2 { Layout.rightMargin: 16 Layout.leftMargin: 16 - text: descriptionText + text: root.descriptionText } BasicButtonType { @@ -58,11 +60,11 @@ DrawerType2 { Layout.rightMargin: 16 Layout.leftMargin: 16 - text: yesButtonText + text: root.yesButtonText clickedFunc: function() { - if (yesButtonFunction && typeof yesButtonFunction === "function") { - yesButtonFunction() + if (root.yesButtonFunction && typeof root.yesButtonFunction === "function") { + root.yesButtonFunction() } } } @@ -80,11 +82,13 @@ DrawerType2 { textColor: AmneziaStyle.color.paleGray borderWidth: 1 - text: noButtonText + visible: root.noButtonText !== "" + + text: root.noButtonText clickedFunc: function() { - if (noButtonFunction && typeof noButtonFunction === "function") { - noButtonFunction() + if (root.noButtonFunction && typeof root.noButtonFunction === "function") { + root.noButtonFunction() } } } diff --git a/client/ui/qml/Pages2/PageHome.qml b/client/ui/qml/Pages2/PageHome.qml index f7233a89..7934e5fb 100644 --- a/client/ui/qml/Pages2/PageHome.qml +++ b/client/ui/qml/Pages2/PageHome.qml @@ -33,6 +33,31 @@ PageType { } } + Connections { + + target: ApiPremV1MigrationController + + function onMigrationFinished() { + apiPremV1MigrationDrawer.closeTriggered() + + var headerText = qsTr("You've successfully switched to the new Amnezia Premium subscription!") + var descriptionText = qsTr("Old keys will no longer work. Please use your new subscription key to connect. \nThank you for staying with us!") + var yesButtonText = qsTr("Continue") + var noButtonText = "" + + var yesButtonFunction = function() { + } + var noButtonFunction = function() { + } + + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + + function onShowMigrationDrawer() { + apiPremV1MigrationDrawer.openTriggered() + } + } + Item { objectName: "homeColumnItem" @@ -429,4 +454,9 @@ PageType { } } } + + ApiPremV1MigrationDrawer { + id: apiPremV1MigrationDrawer + anchors.fill: parent + } } diff --git a/client/ui/qml/Pages2/PageSettingsServerData.qml b/client/ui/qml/Pages2/PageSettingsServerData.qml index 977e669e..995ca74b 100644 --- a/client/ui/qml/Pages2/PageSettingsServerData.qml +++ b/client/ui/qml/Pages2/PageSettingsServerData.qml @@ -257,6 +257,24 @@ PageType { DividerType { visible: ServersModel.getProcessedServerData("isServerFromTelegramApi") } + + LabelWithButtonType { + id: labelWithButton6 + visible: ServersModel.getProcessedServerData("isServerFromTelegramApi") + Layout.fillWidth: true + + text: qsTr("Switch to the new Amnezia Premium subscription") + textColor: AmneziaStyle.color.vibrantRed + + clickedFunction: function() { + PageController.goToPageHome() + ApiPremV1MigrationController.showMigrationDrawer() + } + } + + DividerType { + visible: ServersModel.getProcessedServerData("isServerFromTelegramApi") + } } } }