From 78f504e35c0b877c61a1b07893b61fa4fc23bd09 Mon Sep 17 00:00:00 2001 From: vkamn Date: Wed, 8 Apr 2026 11:21:12 +0700 Subject: [PATCH] feat: new services description (#2412) * feat: iap for apple now use storekit2 * fix: fixed error 101 on connection event * feat: enhance StoreKit2Helper to handle entitlements and improve restore service from App Store functionality * chore: add isInAppPurchase and isTestPurchase in primary config * refactor: use end_date from primary config for renew ui * fix: hide renew button for free * fix: hide renew button for appstore purchases * feat: add new premium info page * feat: add new free info page * chore: minor fixes * refactor: move plan and benefits into separate models * fix: fixed expired status when configs without an end date * feat: add trial api support * chore: add api message parsing for 422 error * feat: move privacy policy and term of use to gateway * feat: add iap support for new premium info page * chore: minor fixes * chore: minor fix * chore: minor fixes * feat: additional parsing for storekit subscription plans * chore: minor codestyle fixes * chore: simplify benefits * chore: hide extend buttons on external premium * feat: add trial error processing * fix: remove wrong check from tiral handler * chore: cleanup --------- Co-authored-by: spectrum --- CMakeLists.txt | 2 +- client/cmake/ios.cmake | 1 + client/cmake/macos_ne.cmake | 1 + client/core/api/apiDefs.h | 6 + client/core/api/apiUtils.cpp | 100 +++- client/core/api/apiUtils.h | 2 + client/core/controllers/coreController.cpp | 9 +- client/core/controllers/coreController.h | 4 + client/core/controllers/gatewayController.cpp | 41 +- client/core/defs.h | 3 + client/core/errorstrings.cpp | 3 + client/images/controls/globe-2.svg | 6 + client/images/controls/infinity.svg | 3 + client/images/controls/smartphone.svg | 4 + client/platforms/ios/StoreKit2Helper.swift | 178 ++++++ client/platforms/ios/StoreKitController.mm | 262 ++------- client/platforms/ios/ios_controller.mm | 110 ++-- client/resources.qrc | 12 +- .../controllers/api/apiConfigsController.cpp | 517 +++++++++++++----- .../ui/controllers/api/apiConfigsController.h | 24 +- .../controllers/api/apiSettingsController.cpp | 7 - .../ui/controllers/connectionController.cpp | 3 + client/ui/controllers/pageController.h | 5 +- client/ui/models/api/apiAccountInfoModel.cpp | 39 +- client/ui/models/api/apiAccountInfoModel.h | 6 +- client/ui/models/api/apiBenefitsModel.cpp | 112 ++++ client/ui/models/api/apiBenefitsModel.h | 43 ++ client/ui/models/api/apiServicesModel.cpp | 122 +++-- client/ui/models/api/apiServicesModel.h | 105 ++-- .../models/api/apiSubscriptionPlansModel.cpp | 131 +++++ .../ui/models/api/apiSubscriptionPlansModel.h | 53 ++ client/ui/models/servers_model.cpp | 47 +- client/ui/models/servers_model.h | 2 +- client/ui/qml/Components/BenefitRow.qml | 65 +++ client/ui/qml/Components/BenefitsPanel.qml | 40 ++ client/ui/qml/Components/ServersListView.qml | 19 +- .../Components/SubscriptionExpiredDrawer.qml | 11 + .../qml/Components/SubscriptionPlanCard.qml | 94 ++++ .../ui/qml/Components/TermsAndPrivacyText.qml | 35 ++ client/ui/qml/Controls2/CardWithIconsType.qml | 219 +++++--- .../qml/Controls2/TextTypes/BadgeTextType.qml | 15 + client/ui/qml/Modules/Style/AmneziaStyle.qml | 9 +- .../PageSettingsApiAvailableCountries.qml | 30 +- .../qml/Pages2/PageSettingsApiServerInfo.qml | 69 +-- .../qml/Pages2/PageSetupWizardApiFreeInfo.qml | 140 +++++ .../Pages2/PageSetupWizardApiPremiumInfo.qml | 198 +++++++ .../Pages2/PageSetupWizardApiServiceInfo.qml | 226 -------- .../Pages2/PageSetupWizardApiServicesList.qml | 9 +- .../Pages2/PageSetupWizardApiTrialEmail.qml | 138 +++++ .../Pages2/PageSetupWizardConfigSource.qml | 14 +- client/ui/qml/Pages2/PageStart.qml | 8 +- 51 files changed, 2372 insertions(+), 930 deletions(-) create mode 100644 client/images/controls/globe-2.svg create mode 100644 client/images/controls/infinity.svg create mode 100644 client/images/controls/smartphone.svg create mode 100644 client/platforms/ios/StoreKit2Helper.swift create mode 100644 client/ui/models/api/apiBenefitsModel.cpp create mode 100644 client/ui/models/api/apiBenefitsModel.h create mode 100644 client/ui/models/api/apiSubscriptionPlansModel.cpp create mode 100644 client/ui/models/api/apiSubscriptionPlansModel.h create mode 100644 client/ui/qml/Components/BenefitRow.qml create mode 100644 client/ui/qml/Components/BenefitsPanel.qml create mode 100644 client/ui/qml/Components/SubscriptionPlanCard.qml create mode 100644 client/ui/qml/Components/TermsAndPrivacyText.qml create mode 100644 client/ui/qml/Controls2/TextTypes/BadgeTextType.qml create mode 100644 client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml create mode 100644 client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml delete mode 100644 client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml create mode 100644 client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index ca4ab5f83..e9146dfd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.14.5) +set(AMNEZIAVPN_VERSION 4.8.15.0) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" diff --git a/client/cmake/ios.cmake b/client/cmake/ios.cmake index b605de484..c581277d1 100644 --- a/client/cmake/ios.cmake +++ b/client/cmake/ios.cmake @@ -121,6 +121,7 @@ target_sources(${PROJECT} PRIVATE ${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift ${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift ${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift + ${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift ) target_sources(${PROJECT} PRIVATE diff --git a/client/cmake/macos_ne.cmake b/client/cmake/macos_ne.cmake index 02dfb4122..2ff334fd3 100644 --- a/client/cmake/macos_ne.cmake +++ b/client/cmake/macos_ne.cmake @@ -131,6 +131,7 @@ target_sources(${PROJECT} PRIVATE ${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift ${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift ${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift + ${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift ) target_sources(${PROJECT} PRIVATE diff --git a/client/core/api/apiDefs.h b/client/core/api/apiDefs.h index 78e8031fc..ebcdced3b 100644 --- a/client/core/api/apiDefs.h +++ b/client/core/api/apiDefs.h @@ -56,8 +56,13 @@ namespace apiDefs constexpr QLatin1String activeDeviceCount("active_device_count"); constexpr QLatin1String maxDeviceCount("max_device_count"); constexpr QLatin1String subscriptionEndDate("subscription_end_date"); + constexpr QLatin1String subscriptionExpiredByServer("subscription_expired_by_server"); + constexpr QLatin1String subscription("subscription"); + constexpr QLatin1String endDate("end_date"); constexpr QLatin1String issuedConfigs("issued_configs"); constexpr QLatin1String subscriptionDescription("subscription_description"); + constexpr QLatin1String termsOfUseUrl("terms_of_use_url"); + constexpr QLatin1String privacyPolicyUrl("privacy_policy_url"); constexpr QLatin1String supportInfo("support_info"); constexpr QLatin1String email("email"); @@ -72,6 +77,7 @@ namespace apiDefs constexpr QLatin1String transactionId("transaction_id"); constexpr QLatin1String isTestPurchase("is_test_purchase"); + constexpr QLatin1String isInAppPurchase("is_in_app_purchase"); constexpr QLatin1String userCountryCode("user_country_code"); diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 92d1e9854..f60e2d497 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -3,11 +3,33 @@ #include #include #include +#include namespace { const QByteArray AMNEZIA_CONFIG_SIGNATURE = QByteArray::fromHex("000000ff"); + constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); + constexpr QLatin1String trialAlreadyUsedMessage("trial subscription already used"); + + QDateTime subscriptionEndUtcFromString(const QString &subscriptionEndDate) + { + if (subscriptionEndDate.isEmpty()) { + return {}; + } + QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs).toUTC(); + if (!endDate.isValid()) { + endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODate).toUTC(); + } + return endDate; + } + + QString apiErrorMessageFromJson(const QJsonObject &jsonObj) + { + const QJsonValue value = jsonObj.value(QStringLiteral("message")); + return value.isString() ? value.toString().trimmed() : QString(); + } + QString escapeUnicode(const QString &input) { QString output; @@ -24,9 +46,30 @@ namespace bool apiUtils::isSubscriptionExpired(const QString &subscriptionEndDate) { - QDateTime now = QDateTime::currentDateTimeUtc(); - QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs); - return endDate < now; + if (subscriptionEndDate.isEmpty()) { + return false; + } + const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate); + if (!endDate.isValid()) { + return false; + } + return endDate <= QDateTime::currentDateTimeUtc(); +} + +bool apiUtils::isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays) +{ + if (subscriptionEndDate.isEmpty()) { + return false; + } + const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate); + if (!endDate.isValid()) { + return false; + } + const QDateTime nowUtc = QDateTime::currentDateTimeUtc(); + if (endDate <= nowUtc) { + return false; + } + return endDate <= nowUtc.addDays(withinDays); } bool apiUtils::isServerFromApi(const QJsonObject &serverConfigObject) @@ -96,41 +139,54 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl const int httpStatusCodeConflict = 409; const int httpStatusCodeNotFound = 404; const int httpStatusCodeNotImplemented = 501; + const int httpStatusCodePaymentRequired = 402; const int httpStatusCodeUnprocessableEntity = 422; if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; return amnezia::ErrorCode::ApiConfigSslError; - } else if (replyError == QNetworkReply::NoError) { + } + if (replyError == QNetworkReply::NoError) { return amnezia::ErrorCode::NoError; - } else if (replyError == QNetworkReply::NetworkError::OperationCanceledError - || replyError == QNetworkReply::NetworkError::TimeoutError) { + } + if (replyError == QNetworkReply::NetworkError::OperationCanceledError + || replyError == QNetworkReply::NetworkError::TimeoutError) { qDebug() << replyError; return amnezia::ErrorCode::ApiConfigTimeoutError; - } else if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) { + } + if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) { qDebug() << replyError; return amnezia::ErrorCode::ApiUpdateRequestError; - } else { - qDebug() << QString::fromUtf8(responseBody); - qDebug() << replyError; - qDebug() << replyErrorString; - qDebug() << httpStatusCode; + } - int httpStatusFromBody = -1; - QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); - if (jsonDoc.isObject()) { - QJsonObject jsonObj = jsonDoc.object(); - httpStatusFromBody = jsonObj.value("http_status").toInt(-1); - } + qDebug() << QString::fromUtf8(responseBody); + qDebug() << replyError; + qDebug() << httpStatusCode; + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + QJsonObject jsonObj = jsonDoc.object(); + const int httpStatusFromBody = jsonObj.value(QStringLiteral("http_status")).toInt(-1); if (httpStatusFromBody == httpStatusCodeConflict) { + if (apiErrorMessageFromJson(jsonObj).contains(trialAlreadyUsedMessage, Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiTrialAlreadyUsedError; + } return amnezia::ErrorCode::ApiConfigLimitError; - } else if (httpStatusFromBody == httpStatusCodeNotFound) { + } + if (httpStatusFromBody == httpStatusCodeNotFound) { return amnezia::ErrorCode::ApiNotFoundError; - } else if (httpStatusFromBody == httpStatusCodeNotImplemented) { + } + if (httpStatusFromBody == httpStatusCodeNotImplemented) { return amnezia::ErrorCode::ApiUpdateRequestError; - } else if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { - return amnezia::ErrorCode::ApiSubscriptionExpiredError; + } + if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { + if (apiErrorMessageFromJson(jsonObj) == unprocessableSubscriptionMessage) { + return amnezia::ErrorCode::ApiSubscriptionExpiredError; + } + return amnezia::ErrorCode::ApiConfigDownloadError; + } + if (httpStatusFromBody == httpStatusCodePaymentRequired) { + return amnezia::ErrorCode::ApiSubscriptionNotActiveError; } return amnezia::ErrorCode::ApiConfigDownloadError; } diff --git a/client/core/api/apiUtils.h b/client/core/api/apiUtils.h index d4e1d9cef..819242a5f 100644 --- a/client/core/api/apiUtils.h +++ b/client/core/api/apiUtils.h @@ -13,6 +13,8 @@ namespace apiUtils bool isSubscriptionExpired(const QString &subscriptionEndDate); + bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 10); + bool isPremiumServer(const QJsonObject &serverConfigObject); apiDefs::ConfigType getConfigType(const QJsonObject &serverConfigObject); diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index dde6ffb29..42a8e2037 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -91,6 +91,12 @@ void CoreController::initModels() m_apiServicesModel.reset(new ApiServicesModel(this)); m_engine->rootContext()->setContextProperty("ApiServicesModel", m_apiServicesModel.get()); + m_apiSubscriptionPlansModel.reset(new ApiSubscriptionPlansModel(this)); + m_engine->rootContext()->setContextProperty("ApiSubscriptionPlansModel", m_apiSubscriptionPlansModel.get()); + + m_apiBenefitsModel.reset(new ApiBenefitsModel(this)); + m_engine->rootContext()->setContextProperty("ApiBenefitsModel", m_apiBenefitsModel.get()); + m_apiCountryModel.reset(new ApiCountryModel(this)); m_engine->rootContext()->setContextProperty("ApiCountryModel", m_apiCountryModel.get()); @@ -151,7 +157,8 @@ void CoreController::initControllers() new ApiSettingsController(m_serversModel, m_apiAccountInfoModel, m_apiCountryModel, m_apiDevicesModel, m_settings)); m_engine->rootContext()->setContextProperty("ApiSettingsController", m_apiSettingsController.get()); - m_apiConfigsController.reset(new ApiConfigsController(m_serversModel, m_apiServicesModel, m_settings)); + m_apiConfigsController.reset( + new ApiConfigsController(m_serversModel, m_apiServicesModel, m_apiSubscriptionPlansModel, m_apiBenefitsModel, m_settings)); m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get()); connect(m_apiConfigsController.get(), &ApiConfigsController::subscriptionRefreshNeeded, this, [this]() { m_apiSettingsController->getAccountInfo(false); }); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 998e7d8d0..fd2e88cad 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -32,9 +32,11 @@ #include "ui/models/protocols/ikev2ConfigModel.h" #endif #include "ui/models/api/apiAccountInfoModel.h" +#include "ui/models/api/apiBenefitsModel.h" #include "ui/models/api/apiCountryModel.h" #include "ui/models/api/apiDevicesModel.h" #include "ui/models/api/apiServicesModel.h" +#include "ui/models/api/apiSubscriptionPlansModel.h" #include "ui/models/appSplitTunnelingModel.h" #include "ui/models/clientManagementModel.h" #include "ui/models/protocols/awgConfigModel.h" @@ -133,6 +135,8 @@ private: QSharedPointer m_clientManagementModel; QSharedPointer m_apiServicesModel; + QSharedPointer m_apiSubscriptionPlansModel; + QSharedPointer m_apiBenefitsModel; QSharedPointer m_apiCountryModel; QSharedPointer m_apiAccountInfoModel; QSharedPointer m_apiDevicesModel; diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 4a2fe8d22..30b4c572d 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -44,9 +44,11 @@ namespace constexpr int httpStatusCodeNotFound = 404; constexpr int httpStatusCodeConflict = 409; - constexpr int httpStatusCodeNotImplemented = 501; + constexpr int httpStatusCodePaymentRequired = 402; constexpr int httpStatusCodeUnprocessableEntity = 422; + + constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, @@ -334,10 +336,16 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS QStringList baseUrls; if (m_isDevEnvironment) { - baseUrls = QString(DEV_S3_ENDPOINT).split(", "); + baseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } else { - baseUrls = QString(PROD_S3_ENDPOINT).split(", "); + baseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } + + if (baseUrls.empty()) { + qDebug() << "empty storage endpoint list"; + return {}; + } + std::random_device randomDevice; std::mt19937 generator(randomDevice()); std::shuffle(baseUrls.begin(), baseUrls.end(), generator); @@ -416,12 +424,14 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep { const QByteArray &responseBody = decryptedResponseBody; - int httpStatus = -1; + int apiHttpStatus = -1; + QString apiErrorMessage; if (isDecryptionSuccessful) { QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); if (jsonDoc.isObject()) { QJsonObject jsonObj = jsonDoc.object(); - httpStatus = jsonObj.value("http_status").toInt(-1); + apiHttpStatus = jsonObj.value("http_status").toInt(-1); + apiErrorMessage = jsonObj.value(QStringLiteral("message")).toString().trimmed(); } } else { qDebug() << "failed to decrypt the data"; @@ -432,10 +442,12 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep qDebug() << "timeout occurred"; qDebug() << replyError; return true; - } else if (responseBody.contains("html")) { + } + if (responseBody.contains("html")) { qDebug() << "the response contains an html tag"; return true; - } else if (httpStatus == httpStatusCodeNotFound) { + } + if (apiHttpStatus == httpStatusCodeNotFound) { if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2) || responseBody.contains(errorResponsePattern3)) { return false; @@ -443,18 +455,25 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep qDebug() << replyError; return true; } - } else if (httpStatus == httpStatusCodeNotImplemented) { + } + if (apiHttpStatus == httpStatusCodeNotImplemented) { if (responseBody.contains(updateRequestResponsePattern)) { return false; } else { qDebug() << replyError; return true; } - } else if (httpStatus == httpStatusCodeConflict) { + } + if (apiHttpStatus == httpStatusCodeConflict) { return false; - } else if (httpStatus == httpStatusCodeUnprocessableEntity) { + } + if (apiHttpStatus == httpStatusCodePaymentRequired) { return false; - } else if (replyError != QNetworkReply::NetworkError::NoError) { + } + if (apiHttpStatus == httpStatusCodeUnprocessableEntity) { + return apiErrorMessage != unprocessableSubscriptionMessage; + } + if (replyError != QNetworkReply::NetworkError::NoError) { qDebug() << replyError; return true; } diff --git a/client/core/defs.h b/client/core/defs.h index 5c24e76c3..731af38ed 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -123,6 +123,9 @@ namespace amnezia ApiUpdateRequestError = 1111, ApiSubscriptionExpiredError = 1112, ApiPurchaseError = 1113, + ApiSubscriptionNotActiveError = 1114, + ApiNoPurchasedSubscriptionsError = 1115, + ApiTrialAlreadyUsedError = 1116, // QFile errors OpenError = 1200, diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 080663bc3..20d094dda 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -80,6 +80,9 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break; case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break; case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break; + case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break; + case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break; + case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email has already been used for trial activation"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/images/controls/globe-2.svg b/client/images/controls/globe-2.svg new file mode 100644 index 000000000..9c2641b41 --- /dev/null +++ b/client/images/controls/globe-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/images/controls/infinity.svg b/client/images/controls/infinity.svg new file mode 100644 index 000000000..bf0d47f83 --- /dev/null +++ b/client/images/controls/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/images/controls/smartphone.svg b/client/images/controls/smartphone.svg new file mode 100644 index 000000000..faf03229c --- /dev/null +++ b/client/images/controls/smartphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/platforms/ios/StoreKit2Helper.swift b/client/platforms/ios/StoreKit2Helper.swift new file mode 100644 index 000000000..c0472b389 --- /dev/null +++ b/client/platforms/ios/StoreKit2Helper.swift @@ -0,0 +1,178 @@ +import Foundation +import StoreKit + +@available(iOS 15.0, macOS 12.0, *) +@objcMembers +public class StoreKit2Helper: NSObject { + + public static let shared = StoreKit2Helper() + private static let errorDomain = "StoreKit2Helper" + + private struct EntitlementInfo { + let transactionId: UInt64 + let originalTransactionId: UInt64 + let productId: String + let purchaseDate: Date + + var dictionary: NSDictionary { + [ + "transactionId": String(transactionId), + "originalTransactionId": String(originalTransactionId), + "productId": productId + ] + } + } + + public func fetchCurrentEntitlements(completion: @escaping (Bool, [NSDictionary]?, NSError?) -> Void) { + Task { @MainActor in + do { + try await AppStore.sync() + + var entitlements: [EntitlementInfo] = [] + for await result in Transaction.currentEntitlements { + switch result { + case .verified(let transaction): + entitlements.append(EntitlementInfo(transactionId: transaction.id, + originalTransactionId: transaction.originalID, + productId: transaction.productID, + purchaseDate: transaction.purchaseDate)) + case .unverified(_, let error): + print("[IAP][StoreKit2] Unverified transaction skipped: \(error.localizedDescription)") + } + } + let sortedEntitlements = entitlements.sorted { lhs, rhs in + if lhs.purchaseDate != rhs.purchaseDate { + return lhs.purchaseDate > rhs.purchaseDate + } + return lhs.transactionId > rhs.transactionId + }.map { $0.dictionary } + completion(true, sortedEntitlements, nil) + } catch { + completion(false, nil, error as NSError) + } + } + } + + public func purchaseProduct(productIdentifier: String, completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void) { + Task { + do { + let products = try await Product.products(for: [productIdentifier]) + guard let product = products.first else { + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 0, description: "Product not found")) + return + } + let result = try await product.purchase() + switch result { + case .success(let verification): + switch verification { + case .verified(let transaction): + await transaction.finish() + completePurchase(completion: completion, success: true, transactionId: String(transaction.id), + productId: transaction.productID, originalTransactionId: String(transaction.originalID), error: nil) + case .unverified(_, let error): + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: error as NSError) + } + case .userCancelled: + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 1, description: "Purchase cancelled")) + case .pending: + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 2, description: "Purchase pending")) + @unknown default: + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 3, description: "Unknown purchase result")) + } + } catch { + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: error as NSError) + } + } + } + + private func storefrontCurrencyCode(for product: Product) -> String { + product.priceFormatStyle.locale.currencyCode ?? "" + } + + private func subscriptionBillingMonths(_ period: Product.SubscriptionPeriod) -> Double { + let periodValue = Double(period.value) + switch period.unit { + case .day: + return periodValue / 30.0 + case .week: + return periodValue * 7.0 / 30.0 + case .month: + return periodValue + case .year: + return periodValue * 12.0 + @unknown default: + return periodValue + } + } + + public func fetchProducts(identifiers: Set, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) { + Task { + do { + let products = try await Product.products(for: identifiers) + let productDicts = products.map { product in productDictionary(for: product) } + let fetchedIds = Set(products.map { $0.id }) + let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) } + DispatchQueue.main.async { completion(productDicts, Array(invalidIdentifiers), nil) } + } catch { + DispatchQueue.main.async { completion([], Array(identifiers), error as NSError) } + } + } + } + + private func makeError(code: Int, description: String) -> NSError { + NSError(domain: Self.errorDomain, code: code, userInfo: [NSLocalizedDescriptionKey: description]) + } + + private func completePurchase(completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void, + success: Bool, + transactionId: String?, + productId: String?, + originalTransactionId: String?, + error: NSError?) { + DispatchQueue.main.async { + completion(success, transactionId, productId, originalTransactionId, error) + } + } + + private func productDictionary(for product: Product) -> NSDictionary { + let currencyCode = storefrontCurrencyCode(for: product) + var productData: [String: Any] = [ + "productId": product.id, + "title": product.displayName, + "description": product.description, + "price": "\(product.price)", + "displayPrice": product.displayPrice, + "currencyCode": currencyCode, + "priceAmount": NSDecimalNumber(decimal: product.price).doubleValue + ] + if let subscription = product.subscription { + let billingMonths = subscriptionBillingMonths(subscription.subscriptionPeriod) + productData["subscriptionBillingMonths"] = billingMonths + if let perMonthPrice = displayPricePerMonth(for: product, billingMonths: billingMonths, currencyCode: currencyCode) { + productData["displayPricePerMonth"] = perMonthPrice + } + } + return productData as NSDictionary + } + + private func displayPricePerMonth(for product: Product, billingMonths: Double, currencyCode: String) -> String? { + if billingMonths <= 1e-6 { + return nil + } + + let perMonthPrice = product.price / Decimal(billingMonths) + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = product.priceFormatStyle.locale + if !currencyCode.isEmpty { + formatter.currencyCode = currencyCode + } + return formatter.string(from: NSDecimalNumber(decimal: perMonthPrice)) + } +} diff --git a/client/platforms/ios/StoreKitController.mm b/client/platforms/ios/StoreKitController.mm index 0a512d023..14a1e39a4 100644 --- a/client/platforms/ios/StoreKitController.mm +++ b/client/platforms/ios/StoreKitController.mm @@ -4,27 +4,20 @@ #import "StoreKitController.h" #import +#import #include #include -API_AVAILABLE(ios(15.0), macos(12.0)) -@interface StoreKitController () -@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success, - NSString *_Nullable transactionId, - NSString *_Nullable productId, - NSString *_Nullable originalTransactionId, - NSError *_Nullable error); -@property (nonatomic, copy) void (^restoreCompletion)(BOOL success, - NSArray *_Nullable restoredTransactions, - NSError *_Nullable error); -@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray *products, - NSArray *invalidIdentifiers, - NSError *_Nullable error); -@property (nonatomic, strong) SKProductsRequest *productsRequest; -@property (nonatomic, strong) NSMutableArray *restoredTransactions; -@end +namespace +{ +QString toQString(NSString *value) +{ + return QString::fromUtf8((value ?: @"").UTF8String); +} +} +API_AVAILABLE(ios(15.0), macos(12.0)) @implementation StoreKitController + (instancetype)sharedInstance @@ -42,17 +35,9 @@ API_AVAILABLE(ios(15.0), macos(12.0)) - (instancetype)init API_AVAILABLE(ios(15.0), macos(12.0)) { self = [super init]; - if (self) { - [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; - } return self; } -- (void)dealloc -{ - [[SKPaymentQueue defaultQueue] removeTransactionObserver:self]; -} - - (void)purchaseProduct:(NSString *)productIdentifier completion:(void (^)(BOOL success, NSString *_Nullable transactionId, @@ -60,41 +45,48 @@ API_AVAILABLE(ios(15.0), macos(12.0)) NSString *_Nullable originalTransactionId, NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) { - self.purchaseCompletion = completion; - - qInfo().noquote() << "[IAP][StoreKit] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String); - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self performPurchaseAsync:productIdentifier]; - }); -} - -- (void)performPurchaseAsync:(NSString *)productIdentifier API_AVAILABLE(ios(15.0), macos(12.0)) -{ - dispatch_async(dispatch_get_main_queue(), ^{ - @try { - SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]]; - request.delegate = self; - [request start]; - - } @catch (NSException *exception) { - NSError *error = [NSError errorWithDomain:@"StoreKitController" - code:1 - userInfo:@{ NSLocalizedDescriptionKey : exception.reason ?: @"Purchase failed" }]; - if (self.purchaseCompletion) { - self.purchaseCompletion(NO, nil, nil, nil, error); - self.purchaseCompletion = nil; - } + qInfo().noquote() << "[IAP][StoreKit2] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String); + [[StoreKit2Helper shared] purchaseProductWithProductIdentifier:productIdentifier + completion:^(BOOL success, + NSString *transactionId, + NSString *productId, + NSString *originalTransactionId, + NSError *error) { + if (success) { + qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << toQString(transactionId) + << "originalTransactionId =" << toQString(originalTransactionId) << "productId =" << toQString(productId); + } else if (error) { + qWarning().noquote() << "[IAP][StoreKit2] Purchase failed:" << toQString(error.localizedDescription); } - }); + if (completion) { + completion(success, transactionId, productId, originalTransactionId, error); + } + }]; } - (void)restorePurchasesWithCompletion:(void (^)(BOOL success, NSArray *_Nullable restoredTransactions, NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) { - self.restoreCompletion = completion; - self.restoredTransactions = [NSMutableArray array]; - [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; + [[StoreKit2Helper shared] fetchCurrentEntitlementsWithCompletion:^(BOOL success, + NSArray *entitlements, + NSError *error) { + if (success) { + qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned" + << (int)(entitlements ? entitlements.count : 0) << "active entitlements"; + for (NSDictionary *entitlement in entitlements) { + qInfo().noquote() << "[IAP][StoreKit2] Active entitlement:" + << "transactionId=" << toQString(entitlement[@"transactionId"]) + << "originalTransactionId=" << toQString(entitlement[@"originalTransactionId"]) + << "productId=" << toQString(entitlement[@"productId"]); + } + } else { + qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" << toQString(error.localizedDescription); + } + if (completion) { + completion(success, entitlements, error); + } + }]; } - (void)fetchProductsWithIdentifiers:(NSSet *)productIdentifiers @@ -102,163 +94,21 @@ API_AVAILABLE(ios(15.0), macos(12.0)) NSArray *invalidIdentifiers, NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) { - self.productsFetchCompletion = completion; - self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers]; - self.productsRequest.delegate = self; - [self.productsRequest start]; -} - -#pragma mark - SKProductsRequestDelegate / SKRequestDelegate - -- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response -{ - if (self.purchaseCompletion) { - SKProduct *product = response.products.firstObject; - if (!product) { - NSError *error = [NSError errorWithDomain:@"StoreKitController" - code:0 - userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }]; - self.purchaseCompletion(NO, nil, nil, nil, error); - self.purchaseCompletion = nil; - self.productsRequest = nil; - return; - } - NSString *currencyCode = [product.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""; - NSString *priceString = [product.price stringValue] ?: @""; - qInfo().noquote() << "[IAP][StoreKit] Received product" << QString::fromUtf8(product.productIdentifier.UTF8String) - << "price=" << QString::fromUtf8(priceString.UTF8String) - << "currency=" << QString::fromUtf8(currencyCode.UTF8String); - SKPayment *payment = [SKPayment paymentWithProduct:product]; - [[SKPaymentQueue defaultQueue] addPayment:payment]; - self.productsRequest = nil; - return; - } - - if (self.productsFetchCompletion) { - NSMutableArray *productDicts = [NSMutableArray array]; - for (SKProduct *p in response.products) { - NSDictionary *productDict = @{ - @"productId": p.productIdentifier, - @"title": p.localizedTitle, - @"description": p.localizedDescription, - @"price": p.price.stringValue, - @"currencyCode": [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"" - }; - [productDicts addObject:productDict]; - NSString *productCurrency = [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""; - NSString *productPrice = [p.price stringValue] ?: @""; - qInfo().noquote() << "[IAP][StoreKit] Fetched product info" << QString::fromUtf8(p.productIdentifier.UTF8String) - << "price=" << QString::fromUtf8(productPrice.UTF8String) - << "currency=" << QString::fromUtf8(productCurrency.UTF8String); - } - - self.productsFetchCompletion(productDicts, response.invalidProductIdentifiers, nil); - self.productsFetchCompletion = nil; - self.productsRequest = nil; - return; - } -} - -- (void)request:(SKRequest *)request didFailWithError:(NSError *)error -{ - if (self.purchaseCompletion) { - self.purchaseCompletion(NO, nil, nil, nil, error); - self.purchaseCompletion = nil; - } - if (self.productsFetchCompletion) { - self.productsFetchCompletion(@[], @[], error); - self.productsFetchCompletion = nil; - } - self.productsRequest = nil; -} - -#pragma mark - SKPaymentTransactionObserver - -- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions -{ - for (SKPaymentTransaction *transaction in transactions) { - switch (transaction.transactionState) { - case SKPaymentTransactionStatePurchased: { - NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transaction.transactionIdentifier; - qInfo().noquote() << "[IAP][StoreKit] Transaction purchased" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String) - << "original=" << QString::fromUtf8((originalTransactionId ?: @"").UTF8String) - << "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String); - - if (self.purchaseCompletion) { - self.purchaseCompletion(YES, - transaction.transactionIdentifier, - transaction.payment.productIdentifier, - originalTransactionId, - nil); - self.purchaseCompletion = nil; + [[StoreKit2Helper shared] fetchProductsWithIdentifiers:productIdentifiers + completion:^(NSArray *products, + NSArray *invalidIdentifiers, + NSError *error) { + if (!error) { + for (NSDictionary *productInfo in products) { + qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << toQString(productInfo[@"productId"]) + << "price=" << toQString(productInfo[@"price"]) + << "currency=" << toQString(productInfo[@"currencyCode"]); } - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; - break; } - case SKPaymentTransactionStateFailed: - qInfo().noquote() << "[IAP][StoreKit] Transaction failed" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String) - << "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String) - << "error=" << QString::fromUtf8(transaction.error.localizedDescription.UTF8String); - if (self.purchaseCompletion) { - self.purchaseCompletion(NO, - transaction.transactionIdentifier, - transaction.payment.productIdentifier, - nil, - transaction.error); - self.purchaseCompletion = nil; - } - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; - break; - case SKPaymentTransactionStateRestored: { - if (self.restoreCompletion) { - NSString *transactionId = transaction.transactionIdentifier ?: @""; - NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transactionId; - NSString *productId = transaction.payment.productIdentifier ?: @""; - - qInfo().noquote() << "[IAP][StoreKit] Transaction restored" - << QString::fromUtf8(transactionId.UTF8String) - << "original=" - << QString::fromUtf8((originalTransactionId ?: @"").UTF8String) - << "product=" - << QString::fromUtf8((productId ?: @"").UTF8String); - - NSDictionary *info = @{ - @"transactionId": transactionId, - @"originalTransactionId": originalTransactionId ?: @"", - @"productId": productId ?: @"" - }; - if (!self.restoredTransactions) { - self.restoredTransactions = [NSMutableArray array]; - } - [self.restoredTransactions addObject:info]; - } - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; - break; + if (completion) { + completion(products ?: @[], invalidIdentifiers ?: @[], error); } - case SKPaymentTransactionStatePurchasing: - case SKPaymentTransactionStateDeferred: - break; - } - } -} - -- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue -{ - if (self.restoreCompletion) { - NSArray *transactions = [self.restoredTransactions copy]; - self.restoreCompletion(YES, transactions, nil); - self.restoreCompletion = nil; - self.restoredTransactions = nil; - } -} - -- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error -{ - if (self.restoreCompletion) { - self.restoreCompletion(NO, nil, error); - self.restoreCompletion = nil; - self.restoredTransactions = nil; - } + }]; } @end diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index fc9498d02..b2a5dcd30 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -179,8 +179,9 @@ bool IosController::initialize() [NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray * _Nullable managers, NSError * _Nullable error) { @try { if (error) { - qDebug() << "IosController::initialize : Error:" << [error.localizedDescription UTF8String]; - emit connectionStateChanged(Vpn::ConnectionState::Error); + qWarning() << "IosController::initialize : loadAllFromPreferences failed:" + << [error.localizedDescription UTF8String] + << "domain:" << [error.domain UTF8String] << "code:" << error.code; ok = false; return; } @@ -397,8 +398,14 @@ void IosController::vpnStatusDidChange(void *pNotification) { NETunnelProviderSession *session = (NETunnelProviderSession *)pNotification; - if (session /* && session == TunnelManager.session */ ) { - qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session; + if (!session) { + return; + } + if (!m_currentTunnel || (NETunnelProviderSession *)m_currentTunnel.connection != session) { + return; + } + + qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session; if (session.status == NEVPNStatusDisconnected) { if (@available(iOS 16.0, *)) { @@ -512,7 +519,6 @@ void IosController::vpnStatusDidChange(void *pNotification) m_statusRequestInFlight = false; } emitConnectionStateIfChanged(nextState); - } } void IosController::vpnConfigurationDidChange(void *pNotification) @@ -844,39 +850,49 @@ void IosController::startTunnel() m_rxBytes = 0; m_txBytes = 0; - [m_currentTunnel setEnabled:YES]; + NETunnelProviderManager *tunnel = m_currentTunnel; + [tunnel setEnabled:YES]; - [m_currentTunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [tunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (saveError) { + qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName + << " Tunnel Save Error" << saveError.localizedDescription.UTF8String << " domain:" + << saveError.domain.UTF8String << " code:" << saveError.code; + emit connectionStateChanged(Vpn::ConnectionState::Error); + return; + } - if (saveError) { - qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName << " Tunnel Save Error" << saveError.localizedDescription.UTF8String; - emit connectionStateChanged(Vpn::ConnectionState::Error); - return; - } + [tunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (loadError) { + qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName + << ": Connect " << protocolName << " Tunnel Load Error" + << loadError.localizedDescription.UTF8String; + emit connectionStateChanged(Vpn::ConnectionState::Error); + return; + } - [m_currentTunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) { - if (loadError) { - qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << ": Connect " << protocolName << " Tunnel Load Error" << loadError.localizedDescription.UTF8String; - emit connectionStateChanged(Vpn::ConnectionState::Error); - return; - } + NSError *startError = nil; + qDebug() << iosStatusToState(tunnel.connection.status); - NSError *startError = nil; - qDebug() << iosStatusToState(m_currentTunnel.connection.status); + BOOL started = [tunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError]; - BOOL started = [m_currentTunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError]; - - if (!started || startError) { - qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Connect " << protocolName << " Tunnel Start Error" - << (startError ? startError.localizedDescription.UTF8String : ""); - emit connectionStateChanged(Vpn::ConnectionState::Error); - } else { - qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Starting the tunnel succeeded"; - } - }]; - }); - }]; + if (!started || startError) { + qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName + << " : Connect " << protocolName << " Tunnel Start Error" + << (startError ? startError.localizedDescription.UTF8String : ""); + emit connectionStateChanged(Vpn::ConnectionState::Error); + } else { + qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName + << " : Starting the tunnel succeeded"; + } + }); + }]; + }); + }]; + }); } bool IosController::isOurManager(NETunnelProviderManager* manager) { @@ -1131,14 +1147,26 @@ void IosController::fetchProducts(const QStringList &productIds, NSArray * _Nonnull invalidIdentifiers, NSError * _Nullable error) { QList outProducts; - for (NSDictionary *p in products) { - QVariantMap m; - m["productId"] = QString::fromUtf8([p[@"productId"] UTF8String]); - m["title"] = QString::fromUtf8([p[@"title"] UTF8String]); - m["description"] = QString::fromUtf8([p[@"description"] UTF8String]); - m["price"] = QString::fromUtf8([p[@"price"] UTF8String]); - m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]); - outProducts.push_back(m); + for (NSDictionary *productInfo in products) { + QVariantMap productData; + productData["productId"] = QString::fromUtf8([productInfo[@"productId"] UTF8String]); + productData["title"] = QString::fromUtf8([productInfo[@"title"] UTF8String]); + productData["description"] = QString::fromUtf8([productInfo[@"description"] UTF8String]); + productData["price"] = QString::fromUtf8([productInfo[@"price"] UTF8String]); + if (productInfo[@"displayPrice"]) { + productData["displayPrice"] = QString::fromUtf8([productInfo[@"displayPrice"] UTF8String]); + } + productData["currencyCode"] = QString::fromUtf8([productInfo[@"currencyCode"] UTF8String]); + if (productInfo[@"priceAmount"]) { + productData["priceAmount"] = [productInfo[@"priceAmount"] doubleValue]; + } + if (productInfo[@"subscriptionBillingMonths"]) { + productData["subscriptionBillingMonths"] = [productInfo[@"subscriptionBillingMonths"] doubleValue]; + } + if (productInfo[@"displayPricePerMonth"]) { + productData["displayPricePerMonth"] = QString::fromUtf8([productInfo[@"displayPricePerMonth"] UTF8String]); + } + outProducts.push_back(productData); } QStringList invalid; diff --git a/client/resources.qrc b/client/resources.qrc index a1e4c656d..51b378af5 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -27,10 +27,12 @@ images/controls/folder-open.svg images/controls/folder-search-2.svg images/controls/gauge.svg + images/controls/globe-2.svg images/controls/github.svg images/controls/help-circle.svg images/controls/history.svg images/controls/home.svg + images/controls/infinity.svg images/controls/info.svg images/controls/mail.svg images/controls/map-pin.svg @@ -55,6 +57,7 @@ images/controls/settings-news.svg images/controls/share-2.svg images/controls/split-tunneling.svg + images/controls/smartphone.svg images/controls/tag.svg images/controls/telegram.svg images/controls/text-cursor.svg @@ -133,6 +136,10 @@ ui/qml/Components/HomeContainersListView.qml ui/qml/Components/HomeSplitTunnelingDrawer.qml ui/qml/Components/InstalledAppsDrawer.qml + ui/qml/Components/BenefitRow.qml + ui/qml/Components/BenefitsPanel.qml + ui/qml/Components/SubscriptionPlanCard.qml + ui/qml/Components/TermsAndPrivacyText.qml ui/qml/Components/QuestionDrawer.qml ui/qml/Components/SelectLanguageDrawer.qml ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -181,6 +188,7 @@ ui/qml/Controls2/TextTypes/LabelTextType.qml ui/qml/Controls2/TextTypes/ListItemTitleType.qml ui/qml/Controls2/TextTypes/ParagraphTextType.qml + ui/qml/Controls2/TextTypes/BadgeTextType.qml ui/qml/Controls2/TextTypes/SmallTextType.qml ui/qml/Controls2/TopCloseButtonType.qml ui/qml/Controls2/VerticalRadioButton.qml @@ -226,7 +234,9 @@ ui/qml/Pages2/PageSettingsNewsDetail.qml ui/qml/Pages2/PageProtocolAwgClientSettings.qml ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml - ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml + ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml + ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml + ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml ui/qml/Pages2/PageSetupWizardApiServicesList.qml ui/qml/Pages2/PageSetupWizardConfigSource.qml ui/qml/Pages2/PageSetupWizardCredentials.qml diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index 987d92408..bda3db971 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -9,9 +9,14 @@ #include "ui/controllers/systemController.h" #include "version.h" #include +#include #include #include +#include +#include #include +#include +#include #include "platforms/ios/ios_controller.h" @@ -39,6 +44,15 @@ namespace constexpr char serviceInfo[] = "service_info"; constexpr char serviceProtocol[] = "service_protocol"; + constexpr char services[] = "services"; + constexpr char serviceDescription[] = "service_description"; + constexpr char subscriptionPlans[] = "subscription_plans"; + constexpr char storeProductId[] = "store_product_id"; + constexpr char priceLabel[] = "price_label"; + constexpr char subtitle[] = "subtitle"; + constexpr char isTrial[] = "is_trial"; + constexpr char minPriceLabel[] = "min_price_label"; + constexpr char apiPayload[] = "api_payload"; constexpr char keyPayload[] = "key_payload"; @@ -47,9 +61,6 @@ namespace constexpr char config[] = "config"; - constexpr char subscription[] = "subscription"; - constexpr char endDate[] = "end_date"; - constexpr char isConnectEvent[] = "is_connect_event"; } @@ -241,13 +252,190 @@ namespace return ErrorCode::NoError; } + +#if defined(Q_OS_IOS) || defined(MACOS_NE) + struct StoreKitPlanQuote { + QString displayPrice; + double priceAmount = 0.0; + double subscriptionBillingMonths = 0.0; + QString displayPricePerMonth; + }; + + constexpr double kOneMonthThreshold = 1.0 + 1e-6; + constexpr double kMonthsFallbackThreshold = 1e-6; + constexpr double kMonthlyPriceEpsilon = 1e-9; + + QStringList collectPremiumStoreProductIds(const QJsonArray &services) + { + QStringList productIds; + QSet seenProductIds; + for (const QJsonValue &serviceValue : services) { + const QJsonObject serviceObject = serviceValue.toObject(); + if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { + continue; + } + const QJsonArray subscriptionPlans = + serviceObject.value(configKey::serviceDescription).toObject().value(configKey::subscriptionPlans).toArray(); + for (const QJsonValue &planValue : subscriptionPlans) { + if (!planValue.isObject()) { + continue; + } + const QString storeProductId = planValue.toObject().value(configKey::storeProductId).toString(); + if (storeProductId.isEmpty() || seenProductIds.contains(storeProductId)) { + continue; + } + seenProductIds.insert(storeProductId); + productIds.append(storeProductId); + } + } + return productIds; + } + + QHash buildStoreKitQuoteMap(const QList &fetchedProducts) + { + QHash quotesByProductId; + quotesByProductId.reserve(fetchedProducts.size()); + + for (const QVariantMap &productInfo : fetchedProducts) { + const QString productId = productInfo.value(QStringLiteral("productId")).toString(); + if (productId.isEmpty()) { + continue; + } + + QString displayPrice = productInfo.value(QStringLiteral("displayPrice")).toString(); + if (displayPrice.isEmpty()) { + const QString price = productInfo.value(QStringLiteral("price")).toString(); + const QString currencyCode = productInfo.value(QStringLiteral("currencyCode")).toString(); + displayPrice = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode); + } + + StoreKitPlanQuote quote; + quote.displayPrice = displayPrice; + quote.priceAmount = productInfo.value(QStringLiteral("priceAmount")).toDouble(); + quote.subscriptionBillingMonths = productInfo.value(QStringLiteral("subscriptionBillingMonths")).toDouble(); + quote.displayPricePerMonth = productInfo.value(QStringLiteral("displayPricePerMonth")).toString(); + quotesByProductId.insert(productId, quote); + } + + return quotesByProductId; + } + + void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data) + { + QJsonArray services = data.value(configKey::services).toArray(); + if (services.isEmpty()) { + return; + } + + const QStringList productIds = collectPremiumStoreProductIds(services); + if (productIds.isEmpty()) { + qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload"; + return; + } + + QList fetchedProducts; + QEventLoop loop; + IosController::Instance()->fetchProducts(productIds, + [&](const QList &products, const QStringList &invalidIds, + const QString &errorString) { + if (!errorString.isEmpty()) { + qWarning().noquote() << "[IAP] StoreKit merge fetch:" << errorString; + } + if (!invalidIds.isEmpty()) { + qWarning().noquote() << "[IAP] Unknown App Store product ids:" << invalidIds; + } + fetchedProducts = products; + loop.quit(); + }); + loop.exec(); + + const QHash quotesByProductId = buildStoreKitQuoteMap(fetchedProducts); + + for (int serviceIndex = 0; serviceIndex < services.size(); ++serviceIndex) { + QJsonObject serviceObject = services.at(serviceIndex).toObject(); + if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { + continue; + } + + QJsonObject descriptionObject = serviceObject.value(configKey::serviceDescription).toObject(); + const QJsonArray sourcePlans = descriptionObject.value(configKey::subscriptionPlans).toArray(); + + QJsonArray mergedPlans; + double minMonthlyAmount = std::numeric_limits::infinity(); + QString minMonthlyDisplay; + + for (const QJsonValue &planValue : sourcePlans) { + if (!planValue.isObject()) { + continue; + } + + QJsonObject planObject = planValue.toObject(); + const QString storeProductId = planObject.value(configKey::storeProductId).toString(); + if (storeProductId.isEmpty()) { + continue; + } + + const auto quoteIterator = quotesByProductId.constFind(storeProductId); + if (quoteIterator == quotesByProductId.cend()) { + continue; + } + + const bool isTrialPlan = planObject.value(configKey::isTrial).toBool(); + const StoreKitPlanQuote "e = *quoteIterator; + planObject.insert(configKey::priceLabel, quote.displayPrice); + + const double months = quote.subscriptionBillingMonths; + if (!isTrialPlan && months > kOneMonthThreshold && !quote.displayPricePerMonth.isEmpty()) { + planObject.insert( + configKey::subtitle, + QCoreApplication::translate("ApiConfigsController", "%1/mo", "IAP: price per month in plan subtitle") + .arg(quote.displayPricePerMonth)); + } + + if (!isTrialPlan && quote.priceAmount > 0.0) { + const double monthsForMin = months > kMonthsFallbackThreshold ? months : 1.0; + const double monthly = quote.priceAmount / monthsForMin; + if (monthly < minMonthlyAmount - kMonthlyPriceEpsilon) { + minMonthlyAmount = monthly; + minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice; + } + } + + mergedPlans.append(planObject); + } + + descriptionObject.insert(configKey::subscriptionPlans, mergedPlans); + if (minMonthlyAmount < std::numeric_limits::infinity() && !minMonthlyDisplay.isEmpty()) { + descriptionObject.insert(configKey::minPriceLabel, + QCoreApplication::translate("ApiConfigsController", "from %1 per month", + "IAP: card footer minimum monthly price from StoreKit") + .arg(minMonthlyDisplay)); + } + serviceObject.insert(configKey::serviceDescription, descriptionObject); + services.replace(serviceIndex, serviceObject); + } + data.insert(configKey::services, services); + } +#endif } ApiConfigsController::ApiConfigsController(const QSharedPointer &serversModel, const QSharedPointer &apiServicesModel, + const QSharedPointer &subscriptionPlansModel, + const QSharedPointer &benefitsModel, const std::shared_ptr &settings, QObject *parent) - : QObject(parent), m_serversModel(serversModel), m_apiServicesModel(apiServicesModel), m_settings(settings) + : QObject(parent) + , m_serversModel(serversModel) + , m_apiServicesModel(apiServicesModel) + , m_subscriptionPlansModel(subscriptionPlansModel) + , m_benefitsModel(benefitsModel) + , m_settings(settings) { + connect(m_apiServicesModel.data(), &ApiServicesModel::serviceSelectionChanged, this, [this]() { + const ApiServicesModel::ApiServicesData serviceData = m_apiServicesModel->selectedServiceData(); + m_subscriptionPlansModel->updateModel(serviceData.subscriptionPlansJson); + m_benefitsModel->updateModel(serviceData.benefits); + }); } bool ApiConfigsController::exportVpnKey(const QString &fileName) @@ -384,51 +572,11 @@ bool ApiConfigsController::fillAvailableServices() } QJsonObject data = QJsonDocument::fromJson(responseBody).object(); - + #if defined(Q_OS_IOS) || defined(MACOS_NE) - QEventLoop waitProducts; - bool productsFetched = false; - QString productPrice; - QString productCurrency; - - IosController::Instance()->fetchProducts(QStringList() << QStringLiteral("amnezia_premium_6_month"), - [&](const QList &products, - const QStringList &invalidIds, - const QString &errorString) { - if (!errorString.isEmpty() || products.isEmpty()) { - qWarning().noquote() << "[IAP] Failed to fetch product price:" << errorString; - } else { - const auto &product = products.first(); - productPrice = product.value("price").toString(); - productCurrency = product.value("currencyCode").toString(); - productsFetched = true; - qInfo().noquote() << "[IAP] Fetched product price:" << productPrice << productCurrency; - } - waitProducts.quit(); - }); - waitProducts.exec(); - - if (productsFetched && !productPrice.isEmpty()) { - QJsonArray services = data.value("services").toArray(); - for (int i = 0; i < services.size(); ++i) { - QJsonObject service = services[i].toObject(); - if (service.value(configKey::serviceType).toString() == serviceType::amneziaPremium) { - QJsonObject serviceInfo = service.value(configKey::serviceInfo).toObject(); - QString formattedPrice = productPrice; - if (!productCurrency.isEmpty()) { - formattedPrice += " " + productCurrency; - } - serviceInfo["price"] = formattedPrice; - service[configKey::serviceInfo] = serviceInfo; - services[i] = service; - data["services"] = services; - qInfo().noquote() << "[IAP] Updated premium service price in data:" << formattedPrice; - break; - } - } - } + mergeStoreKitPricesIntoPremiumPlans(data); #endif - + m_apiServicesModel->updateModel(data); if (m_apiServicesModel->rowCount() > 0) { m_apiServicesModel->setServiceIndex(0); @@ -439,39 +587,42 @@ bool ApiConfigsController::fillAvailableServices() bool ApiConfigsController::importService() { #if defined(Q_OS_IOS) || defined(MACOS_NE) - bool isIosOrMacOsNe = true; + const bool isIosOrMacOsNe = true; #else - bool isIosOrMacOsNe = false; + const bool isIosOrMacOsNe = false; #endif if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) { if (isIosOrMacOsNe) { - importSerivceFromAppStore(); - return true; + return importPremiumFromAppStore(QString()); } } else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) { - importServiceFromGateway(); - return true; + return importFreeFromGateway(); } return false; } -bool ApiConfigsController::importSerivceFromAppStore() +bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProductId) { #if defined(Q_OS_IOS) || defined(MACOS_NE) + QString productId = storeProductId.trimmed(); + if (productId.isEmpty()) { + productId = QStringLiteral("amnezia_premium_6_month"); + } + bool purchaseOk = false; QString originalTransactionId; QString storeTransactionId; - QString storeProductId; + QString purchasedStoreProductId; QString purchaseError; QEventLoop waitPurchase; - IosController::Instance()->purchaseProduct(QStringLiteral("amnezia_premium_6_month"), - [&](bool success, const QString &txId, const QString &purchasedProductId, - const QString &originalTxId, const QString &errorString) { + IosController::Instance()->purchaseProduct(productId, + [&](bool success, const QString &transactionId, const QString &purchasedProductId, + const QString &originalTransactionIdResponse, const QString &errorString) { purchaseOk = success; - originalTransactionId = originalTxId; - storeTransactionId = txId; - storeProductId = purchasedProductId; + originalTransactionId = originalTransactionIdResponse; + storeTransactionId = transactionId; + purchasedStoreProductId = purchasedProductId; purchaseError = errorString; waitPurchase.quit(); }); @@ -483,7 +634,7 @@ bool ApiConfigsController::importSerivceFromAppStore() return false; } qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId - << "originalTransactionId =" << originalTransactionId << "productId =" << storeProductId; + << "originalTransactionId =" << originalTransactionId << "productId =" << purchasedStoreProductId; GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), @@ -507,18 +658,26 @@ bool ApiConfigsController::importSerivceFromAppStore() return false; } - errorCode = importServiceFromBilling(responseBody, isTestPurchase); + int duplicateServerIndex = -1; + errorCode = importServiceFromBilling(responseBody, isTestPurchase, duplicateServerIndex); + if (errorCode == ErrorCode::ApiConfigAlreadyAdded) { + emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); + return true; + } if (errorCode != ErrorCode::NoError) { emit errorOccurred(errorCode); return false; } - - emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); -#endif + emit installServerFromApiFinished( + tr("%1 was added to the app.").arg(m_apiServicesModel->getSelectedServiceName())); return true; +#else + Q_UNUSED(storeProductId); + return false; +#endif } -bool ApiConfigsController::restoreSerivceFromAppStore() +bool ApiConfigsController::restoreServiceFromAppStore() { #if defined(Q_OS_IOS) || defined(MACOS_NE) const QString premiumServiceType = QStringLiteral("amnezia-premium"); @@ -534,20 +693,12 @@ bool ApiConfigsController::restoreSerivceFromAppStore() return false; } - // Ensure we have a valid premium selection for gateway requests - bool premiumSelected = false; - for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) { - m_apiServicesModel->setServiceIndex(i); - if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) { - premiumSelected = true; - break; - } - } - - if (!premiumSelected) { + const int premiumServiceIndex = m_apiServicesModel->serviceIndexForType(premiumServiceType); + if (premiumServiceIndex < 0) { emit errorOccurred(ErrorCode::ApiServicesMissingError); return false; } + m_apiServicesModel->setServiceIndex(premiumServiceIndex); bool restoreSuccess = false; QList restoredTransactions; @@ -569,15 +720,23 @@ bool ApiConfigsController::restoreSerivceFromAppStore() } if (restoredTransactions.isEmpty()) { - qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned"; - emit errorOccurred(ErrorCode::ApiPurchaseError); + qInfo().noquote() << "[IAP] Restore completed, but no active entitlements found"; + emit errorOccurred(ErrorCode::ApiNoPurchasedSubscriptionsError); return false; } + const bool isTestPurchase = IosController::Instance()->isTestFlight(); + const QString serviceType = m_apiServicesModel->getSelectedServiceType(); + const QString serviceProtocol = m_apiServicesModel->getSelectedServiceProtocol(); + const QString countryCode = m_apiServicesModel->getCountryCode(); + const QString appLanguage = m_settings->getAppLanguage().name().split("_").first(); + const QString installationUuid = m_settings->getInstallationUuid(true); + bool hasInstalledConfig = false; bool duplicateConfigAlreadyPresent = false; - int duplicateCount = 0; - QSet processedTransactions; + int duplicateServerIndex = -1; + QSet processedOriginalTransactionIds; + for (const QVariantMap &transaction : restoredTransactions) { const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString(); const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString(); @@ -588,28 +747,28 @@ bool ApiConfigsController::restoreSerivceFromAppStore() continue; } - if (processedTransactions.contains(originalTransactionId)) { - duplicateCount++; + if (processedOriginalTransactionIds.contains(originalTransactionId)) { + qInfo().noquote() << "[IAP] Skipping duplicate restored transaction" << originalTransactionId; continue; } - processedTransactions.insert(originalTransactionId); + processedOriginalTransactionIds.insert(originalTransactionId); qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId << "originalTransactionId =" << originalTransactionId << "productId =" << productId; GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), - m_settings->getAppLanguage().name().split("_").first(), - m_settings->getInstallationUuid(true), - m_apiServicesModel->getCountryCode(), + appLanguage, + installationUuid, + countryCode, "", - m_apiServicesModel->getSelectedServiceType(), - m_apiServicesModel->getSelectedServiceProtocol(), + serviceType, + serviceProtocol, QJsonObject() }; QJsonObject apiPayload = gatewayRequestData.toJsonObject(); apiPayload[apiDefs::key::transactionId] = originalTransactionId; - auto isTestPurchase = IosController::Instance()->isTestFlight(); + QByteArray responseBody; ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError) { @@ -618,34 +777,42 @@ bool ApiConfigsController::restoreSerivceFromAppStore() continue; } - ErrorCode installError = importServiceFromBilling(responseBody, isTestPurchase); + int currentDuplicateServerIndex = -1; + errorCode = importServiceFromBilling(responseBody, isTestPurchase, currentDuplicateServerIndex); if (errorCode == ErrorCode::ApiConfigAlreadyAdded) { duplicateConfigAlreadyPresent = true; - qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId - << "because subscription config with the same vpn_key already exists"; - } else if (errorCode != ErrorCode::NoError) { - qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId; - } else { - hasInstalledConfig = true; + if (duplicateServerIndex < 0) { + duplicateServerIndex = currentDuplicateServerIndex; + } + qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists" << originalTransactionId; + continue; } + + if (errorCode != ErrorCode::NoError) { + qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId + << "errorCode =" << static_cast(errorCode); + continue; + } + + hasInstalledConfig = true; } if (!hasInstalledConfig) { - const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError; - emit errorOccurred(restoreError); + if (duplicateConfigAlreadyPresent) { + emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); + return true; + } + + emit errorOccurred(ErrorCode::ApiPurchaseError); return false; } emit installServerFromApiFinished(tr("Subscription restored successfully.")); - if (duplicateCount > 0) { - qInfo().noquote() << "[IAP] Skipped" << duplicateCount - << "duplicate restored transactions for original transaction IDs already processed"; - } #endif return true; } -bool ApiConfigsController::importServiceFromGateway() +bool ApiConfigsController::importFreeFromGateway() { GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), @@ -697,6 +864,72 @@ bool ApiConfigsController::importServiceFromGateway() } } +bool ApiConfigsController::importTrialFromGateway(const QString &email) +{ + emit trialEmailError(QString()); + + const QString trimmedEmail = email.trimmed(); + if (trimmedEmail.isEmpty()) { + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return false; + } + + GatewayRequestData gatewayRequestData { QSysInfo::productType(), + QString(APP_VERSION), + m_settings->getAppLanguage().name().split("_").first(), + m_settings->getInstallationUuid(true), + m_apiServicesModel->getCountryCode(), + "", + m_apiServicesModel->getSelectedServiceType(), + m_apiServicesModel->getSelectedServiceProtocol(), + QJsonObject() }; + + ProtocolData protocolData = generateProtocolData(gatewayRequestData.serviceProtocol); + + QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + appendProtocolDataToApiPayload(gatewayRequestData.serviceProtocol, protocolData, apiPayload); + apiPayload.insert(apiDefs::key::email, trimmedEmail); + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(QString("%1v1/trial"), apiPayload, responseBody); + if (errorCode != ErrorCode::NoError) { + if (errorCode == ErrorCode::ApiTrialAlreadyUsedError) { + emit trialEmailError(tr("This email has already been used for trial activation. If you like the service, you can buy Premium.")); + return false; + } + emit errorOccurred(errorCode); + return false; + } + + QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object(); + QString key = responseObject.value(apiDefs::key::config).toString(); + if (key.isEmpty()) { + qWarning().noquote() << "[Trial] trial response does not contain config field"; + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return false; + } + + key.replace(QStringLiteral("vpn://"), QString()); + QByteArray configBytes = QByteArray::fromBase64(key.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + QByteArray uncompressed = qUncompress(configBytes); + if (!uncompressed.isEmpty()) { + configBytes = uncompressed; + } + if (configBytes.isEmpty()) { + qWarning().noquote() << "[Trial] trial response config payload is empty"; + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return false; + } + + QJsonObject configObject = QJsonDocument::fromJson(configBytes).object(); + quint16 crc = qChecksum(QJsonDocument(configObject).toJson()); + configObject.insert(config_key::crc, crc); + m_serversModel->addServer(configObject); + + emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); + return true; +} + bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName, bool reloadServiceConfig) { @@ -740,6 +973,12 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const newApiConfig.insert(configKey::serviceType, apiConfig.value(configKey::serviceType)); newApiConfig.insert(configKey::serviceProtocol, apiConfig.value(configKey::serviceProtocol)); newApiConfig.insert(apiDefs::key::vpnKey, apiConfig.value(apiDefs::key::vpnKey)); + if (apiConfig.contains(apiDefs::key::isInAppPurchase)) { + newApiConfig.insert(apiDefs::key::isInAppPurchase, apiConfig.value(apiDefs::key::isInAppPurchase)); + } + if (apiConfig.contains(apiDefs::key::isTestPurchase)) { + newApiConfig.insert(apiDefs::key::isTestPurchase, apiConfig.value(apiDefs::key::isTestPurchase)); + } newServerConfig.insert(configKey::apiConfig, newApiConfig); newServerConfig.insert(configKey::authData, gatewayRequestData.authData); @@ -765,7 +1004,14 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const return true; } else { if (errorCode == ErrorCode::ApiSubscriptionExpiredError) { - emit subscriptionExpiredOnServer(); + if (!apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) { + apiConfig.insert(apiDefs::key::subscriptionExpiredByServer, true); + serverConfig.insert(configKey::apiConfig, apiConfig); + m_serversModel->editServer(serverConfig, serverIndex); + emit subscriptionExpiredOnServer(); + } else { + emit errorOccurred(errorCode); + } } else { emit errorOccurred(errorCode); } @@ -954,43 +1200,63 @@ QString ApiConfigsController::getVpnKey() return m_vpnKey; } -ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase) +ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase, + int &duplicateServerIndex) { -#ifdef Q_OS_IOS +#if defined(Q_OS_IOS) || defined(MACOS_NE) + duplicateServerIndex = -1; QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object(); - QString key = responseObject.value(QStringLiteral("key")).toString(); - if (key.isEmpty()) { + const QString rawVpnKey = responseObject.value(QStringLiteral("key")).toString(); + if (rawVpnKey.isEmpty()) { qWarning().noquote() << "[IAP] Subscription response does not contain a key field"; return ErrorCode::ApiPurchaseError; } - if (m_serversModel->hasServerWithVpnKey(key)) { + QString normalizedVpnKey = rawVpnKey; + normalizedVpnKey.replace(QStringLiteral("vpn://"), QString()); + + duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey); + if (duplicateServerIndex >= 0) { qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; return ErrorCode::ApiConfigAlreadyAdded; } - QString normalizedKey = key; - normalizedKey.replace(QStringLiteral("vpn://"), QString()); - - QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); - QByteArray configUncompressed = qUncompress(configString); - if (!configUncompressed.isEmpty()) { - configString = configUncompressed; + QByteArray configPayload = + QByteArray::fromBase64(normalizedVpnKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + QByteArray configUncompressed = qUncompress(configPayload); + const bool payloadWasCompressed = !configUncompressed.isEmpty(); + if (payloadWasCompressed) { + configPayload = configUncompressed; } - if (configString.isEmpty()) { + if (configPayload.isEmpty()) { qWarning().noquote() << "[IAP] Subscription response config payload is empty"; return ErrorCode::ApiPurchaseError; } - QJsonObject configObject = QJsonDocument::fromJson(configString).object(); + QJsonObject configObject = QJsonDocument::fromJson(configPayload).object(); + + auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject(); + apiConfig.insert(apiDefs::key::isTestPurchase, isTestPurchase); + apiConfig.insert(apiDefs::key::isInAppPurchase, true); + configObject.insert(apiDefs::key::apiConfig, apiConfig); + + configPayload = QJsonDocument(configObject).toJson(); + if (payloadWasCompressed) { + configPayload = qCompress(configPayload, 8); + } + normalizedVpnKey = QString(configPayload.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); + + duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey); + if (duplicateServerIndex >= 0) { + qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; + return ErrorCode::ApiConfigAlreadyAdded; + } + + apiConfig.insert(apiDefs::key::vpnKey, normalizedVpnKey); + configObject.insert(apiDefs::key::apiConfig, apiConfig); quint16 crc = qChecksum(QJsonDocument(configObject).toJson()); - auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject(); - apiConfig[apiDefs::key::vpnKey] = normalizedKey; - apiConfig[apiDefs::key::isTestPurchase] = isTestPurchase; - - configObject.insert(apiDefs::key::apiConfig, apiConfig); configObject.insert(config_key::crc, crc); m_serversModel->addServer(configObject); @@ -998,6 +1264,7 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo #else Q_UNUSED(responseBody) Q_UNUSED(isTestPurchase) + duplicateServerIndex = -1; return ErrorCode::NoError; #endif } diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index ca5598e1e..68f6565d0 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -1,10 +1,13 @@ #ifndef APICONFIGSCONTROLLER_H #define APICONFIGSCONTROLLER_H +#include #include #include "configurators/openvpn_configurator.h" +#include "ui/models/api/apiBenefitsModel.h" #include "ui/models/api/apiServicesModel.h" +#include "ui/models/api/apiSubscriptionPlansModel.h" #include "ui/models/servers_model.h" class ApiConfigsController : public QObject @@ -12,7 +15,9 @@ class ApiConfigsController : public QObject Q_OBJECT public: ApiConfigsController(const QSharedPointer &serversModel, const QSharedPointer &apiServicesModel, - const std::shared_ptr &settings, QObject *parent = nullptr); + const QSharedPointer &subscriptionPlansModel, + const QSharedPointer &benefitsModel, const std::shared_ptr &settings, + QObject *parent = nullptr); Q_PROPERTY(QList qrCodes READ getQrCodes NOTIFY vpnKeyExportReady) Q_PROPERTY(int qrCodesCount READ getQrCodesCount NOTIFY vpnKeyExportReady) @@ -27,9 +32,10 @@ public slots: bool fillAvailableServices(); bool importService(); - bool importSerivceFromAppStore(); - bool restoreSerivceFromAppStore(); - bool importServiceFromGateway(); + bool importPremiumFromAppStore(const QString &storeProductId); + bool restoreServiceFromAppStore(); + bool importFreeFromGateway(); + bool importTrialFromGateway(const QString &email); bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName, bool reloadServiceConfig = false); bool updateServiceFromTelegram(const int serverIndex); @@ -43,10 +49,11 @@ public slots: signals: void errorOccurred(ErrorCode errorCode); + void trialEmailError(const QString &message); void subscriptionExpiredOnServer(); void subscriptionRefreshNeeded(); - void installServerFromApiFinished(const QString &message); + void installServerFromApiFinished(const QString &message, int preferredDefaultServerIndex = -1); void changeApiCountryFinished(const QString &message); void reloadServerFromApiFinished(const QString &message); void updateServerFromApiFinished(); @@ -59,7 +66,7 @@ private: QString getVpnKey(); ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false); - ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase); + ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase, int &duplicateServerIndex); QList m_qrCodes; QString m_vpnKey; @@ -67,6 +74,9 @@ private: QSharedPointer m_serversModel; QSharedPointer m_apiServicesModel; std::shared_ptr m_settings; + + QSharedPointer m_subscriptionPlansModel; + QSharedPointer m_benefitsModel; }; -#endif // APICONFIGSCONTROLLER_H +#endif diff --git a/client/ui/controllers/api/apiSettingsController.cpp b/client/ui/controllers/api/apiSettingsController.cpp index 78ac5c240..d96437666 100644 --- a/client/ui/controllers/api/apiSettingsController.cpp +++ b/client/ui/controllers/api/apiSettingsController.cpp @@ -78,13 +78,6 @@ bool ApiSettingsController::getAccountInfo(bool reload) QJsonObject accountInfo = QJsonDocument::fromJson(responseBody).object(); m_apiAccountInfoModel->updateModel(accountInfo, serverConfig); - QString subscriptionEndDate = accountInfo.value(apiDefs::key::subscriptionEndDate).toString(); - if (!subscriptionEndDate.isEmpty()) { - apiConfig.insert(apiDefs::key::subscriptionEndDate, subscriptionEndDate); - serverConfig.insert(configKey::apiConfig, apiConfig); - m_serversModel->editServer(serverConfig, processedIndex); - } - if (reload) { updateApiCountryModel(); updateApiDevicesModel(); diff --git a/client/ui/controllers/connectionController.cpp b/client/ui/controllers/connectionController.cpp index 23f43bc1f..d84b9c898 100644 --- a/client/ui/controllers/connectionController.cpp +++ b/client/ui/controllers/connectionController.cpp @@ -6,6 +6,7 @@ #include #endif +#include "amnezia_application.h" #include "utilities.h" #include "core/controllers/vpnConfigurationController.h" #include "version.h" @@ -81,6 +82,8 @@ void ConnectionController::onConnectionStateChanged(Vpn::ConnectionState state) m_connectionStateText = tr("Connecting..."); switch (state) { case Vpn::ConnectionState::Connected: { + amnApp->networkManager()->clearConnectionCache(); + m_isConnectionInProgress = false; m_isConnected = true; m_connectionStateText = tr("Connected"); diff --git a/client/ui/controllers/pageController.h b/client/ui/controllers/pageController.h index 529106343..001d86d48 100644 --- a/client/ui/controllers/pageController.h +++ b/client/ui/controllers/pageController.h @@ -59,7 +59,7 @@ namespace PageLoader PageSetupWizardViewConfig, PageSetupWizardQrReader, PageSetupWizardApiServicesList, - PageSetupWizardApiServiceInfo, + PageSetupWizardApiFreeInfo, PageProtocolOpenVpnSettings, PageProtocolShadowSocksSettings, @@ -76,6 +76,9 @@ namespace PageLoader PageShareFullAccess, PageShareConnection, + PageSetupWizardApiPremiumInfo, + PageSetupWizardApiTrialEmail, + PageDevMenu }; Q_ENUM_NS(PageEnum) diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index b5fd6f556..4e37a98c6 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -57,6 +57,11 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const || m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; } + case IsSubscriptionRenewalAvailableRole: { + return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2 + || m_accountInfoData.configType == apiDefs::ConfigType::AmneziaTrialV2 + || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; + } case HasExpiredWorkerRole: { for (int i = 0; i < m_issuedConfigsInfo.size(); i++) { QJsonObject issuedConfigObject = m_issuedConfigsInfo.at(i).toObject(); @@ -77,16 +82,31 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const return false; } case IsSubscriptionExpiredRole: { - if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) return false; - if (m_accountInfoData.subscriptionEndDate.isEmpty()) return false; + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) { + return false; + } + if (m_accountInfoData.isInAppPurchase) { + return false; + } + 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(10); + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) { + return false; + } + if (m_accountInfoData.isInAppPurchase) { + return false; + } + if (m_accountInfoData.subscriptionEndDate.isEmpty()) { + return false; + } + return apiUtils::isSubscriptionExpiringSoon(m_accountInfoData.subscriptionEndDate); + } + case IsInAppPurchaseRole: { + return m_accountInfoData.isInAppPurchase; } } @@ -108,6 +128,9 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons accountInfoData.configType = apiUtils::getConfigType(serverConfig); + const QJsonObject apiConfig = serverConfig.value(apiDefs::key::apiConfig).toObject(); + accountInfoData.isInAppPurchase = apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false); + accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString(); for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) { @@ -177,10 +200,12 @@ QHash ApiAccountInfoModel::roleNames() const roles[ConnectedDevicesRole] = "connectedDevices"; roles[ServiceDescriptionRole] = "serviceDescription"; roles[IsComponentVisibleRole] = "isComponentVisible"; + roles[IsSubscriptionRenewalAvailableRole] = "isSubscriptionRenewalAvailable"; roles[HasExpiredWorkerRole] = "hasExpiredWorker"; roles[IsProtocolSelectionSupportedRole] = "isProtocolSelectionSupported"; roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; + roles[IsInAppPurchaseRole] = "isInAppPurchase"; return roles; } diff --git a/client/ui/models/api/apiAccountInfoModel.h b/client/ui/models/api/apiAccountInfoModel.h index 882a9c729..fec24cb2d 100644 --- a/client/ui/models/api/apiAccountInfoModel.h +++ b/client/ui/models/api/apiAccountInfoModel.h @@ -18,10 +18,12 @@ public: ServiceDescriptionRole, EndDateRole, IsComponentVisibleRole, + IsSubscriptionRenewalAvailableRole, HasExpiredWorkerRole, IsProtocolSelectionSupportedRole, IsSubscriptionExpiredRole, - IsSubscriptionExpiringSoonRole + IsSubscriptionExpiringSoonRole, + IsInAppPurchaseRole }; explicit ApiAccountInfoModel(QObject *parent = nullptr); @@ -57,6 +59,8 @@ private: QStringList supportedProtocols; QString subscriptionDescription; + + bool isInAppPurchase = false; }; AccountInfoData m_accountInfoData; diff --git a/client/ui/models/api/apiBenefitsModel.cpp b/client/ui/models/api/apiBenefitsModel.cpp new file mode 100644 index 000000000..42b79c9bb --- /dev/null +++ b/client/ui/models/api/apiBenefitsModel.cpp @@ -0,0 +1,112 @@ +#include "apiBenefitsModel.h" + +#include +#include +#include +#include + +namespace +{ +namespace configKey +{ + constexpr char title[] = "title"; + constexpr char body[] = "body"; + constexpr char icon[] = "icon"; + constexpr char accent[] = "accent"; +} + +QString gatewayIconKeyToUrl(const QString &iconKey) +{ + if (iconKey.startsWith(QLatin1String("qrc:"))) { + return iconKey; + } + static const QHash map = { + { QStringLiteral("globe-2"), QStringLiteral("qrc:/images/controls/globe-2.svg") }, + { QStringLiteral("smartphone"), QStringLiteral("qrc:/images/controls/smartphone.svg") }, + { QStringLiteral("gauge"), QStringLiteral("qrc:/images/controls/gauge.svg") }, + { QStringLiteral("infinity"), QStringLiteral("qrc:/images/controls/infinity.svg") }, + { QStringLiteral("tag"), QStringLiteral("qrc:/images/controls/tag.svg") }, + { QStringLiteral("history"), QStringLiteral("qrc:/images/controls/history.svg") }, + { QStringLiteral("info"), QStringLiteral("qrc:/images/controls/info.svg") }, + { QStringLiteral("app"), QStringLiteral("qrc:/images/controls/app.svg") }, + { QStringLiteral("download"), QStringLiteral("qrc:/images/controls/download.svg") }, + { QStringLiteral("help-circle"), QStringLiteral("qrc:/images/controls/help-circle.svg") }, + }; + return map.value(iconKey, QStringLiteral("qrc:/images/controls/info.svg")); +} +} + +ApiBenefitsModel::ApiBenefitsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int ApiBenefitsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_serviceBenefits.size(); +} + +QVariant ApiBenefitsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_serviceBenefits.size()) { + return {}; + } + const ServiceBenefitItem &item = m_serviceBenefits.at(index.row()); + switch (role) { + case IconRole: + return item.icon; + case TitleRole: + return item.title; + case BodyRole: + return item.body; + case AccentRole: + return item.accent; + default: + return {}; + } +} + +QHash ApiBenefitsModel::roleNames() const +{ + return { + { IconRole, "icon" }, + { TitleRole, "title" }, + { BodyRole, "body" }, + { AccentRole, "accent" }, + }; +} + +void ApiBenefitsModel::updateModel(const QJsonArray &benefits) +{ + beginResetModel(); + m_serviceBenefits.clear(); + for (const QJsonValue &benefitValue : benefits) { + if (!benefitValue.isObject()) { + continue; + } + const QJsonObject benefitObject = benefitValue.toObject(); + QString title = benefitObject.value(configKey::title).toString(); + QString body = benefitObject.value(configKey::body).toString(); + const QString iconKey = benefitObject.value(configKey::icon).toString(); + if (title.isEmpty() && body.isEmpty()) { + continue; + } + ServiceBenefitItem item; + item.icon = gatewayIconKeyToUrl(iconKey); + item.title = std::move(title); + item.body = std::move(body); + item.accent = benefitObject.value(configKey::accent).toBool(); + m_serviceBenefits.append(std::move(item)); + } + endResetModel(); +} + +void ApiBenefitsModel::clear() +{ + beginResetModel(); + m_serviceBenefits.clear(); + endResetModel(); +} diff --git a/client/ui/models/api/apiBenefitsModel.h b/client/ui/models/api/apiBenefitsModel.h new file mode 100644 index 000000000..c6b8465f2 --- /dev/null +++ b/client/ui/models/api/apiBenefitsModel.h @@ -0,0 +1,43 @@ +#ifndef APIBENEFITSMODEL_H +#define APIBENEFITSMODEL_H + +#include +#include +#include +#include + +class ApiBenefitsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + IconRole = Qt::UserRole + 1, + TitleRole, + BodyRole, + AccentRole + }; + Q_ENUM(Roles) + + explicit ApiBenefitsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void updateModel(const QJsonArray &benefits); + void clear(); + +private: + struct ServiceBenefitItem + { + QString icon; + QString title; + QString body; + bool accent = false; + }; + + QVector m_serviceBenefits; +}; + +#endif diff --git a/client/ui/models/api/apiServicesModel.cpp b/client/ui/models/api/apiServicesModel.cpp index 7d831f48c..d309afd84 100644 --- a/client/ui/models/api/apiServicesModel.cpp +++ b/client/ui/models/api/apiServicesModel.cpp @@ -1,7 +1,11 @@ #include "apiServicesModel.h" +#include +#include +#include #include +#include "core/api/apiDefs.h" #include "logger.h" namespace @@ -17,15 +21,9 @@ namespace constexpr char serviceProtocol[] = "service_protocol"; constexpr char serviceDescription[] = "service_description"; - constexpr char name[] = "name"; - constexpr char price[] = "price"; - constexpr char speed[] = "speed"; - constexpr char timelimit[] = "timelimit"; - constexpr char region[] = "region"; - constexpr char description[] = "description"; constexpr char cardDescription[] = "card_description"; - constexpr char features[] = "features"; + constexpr char serviceName[] = "service_name"; constexpr char availableCountries[] = "available_countries"; @@ -33,19 +31,21 @@ namespace constexpr char isAvailable[] = "is_available"; - constexpr char subscription[] = "subscription"; - constexpr char endDate[] = "end_date"; + constexpr char subscriptionPlans[] = "subscription_plans"; + constexpr char minPriceLabel[] = "min_price_label"; + constexpr char benefits[] = "benefits"; } namespace serviceType { constexpr char amneziaFree[] = "amnezia-free"; constexpr char amneziaPremium[] = "amnezia-premium"; - constexpr char amneziaTrial[] = "amnezia-trial"; } } -ApiServicesModel::ApiServicesModel(QObject *parent) : QAbstractListModel(parent) +ApiServicesModel::ApiServicesModel(QObject *parent) + : QAbstractListModel(parent) + , m_selectedServiceIndex(0) { } @@ -69,9 +69,8 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const return apiServiceData.serviceInfo.name; } case CardDescriptionRole: { - auto speed = apiServiceData.serviceInfo.speed; - if (serviceType == serviceType::amneziaPremium || serviceType == serviceType::amneziaTrial) { - return apiServiceData.serviceInfo.cardDescription.arg(speed); + if (serviceType == serviceType::amneziaPremium) { + return apiServiceData.serviceInfo.cardDescription; } else if (serviceType == serviceType::amneziaFree) { QString description = apiServiceData.serviceInfo.cardDescription; if (!isServiceAvailable) { @@ -92,44 +91,29 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const } return true; } - case SpeedRole: { - return tr("%1 MBit/s").arg(apiServiceData.serviceInfo.speed); - } - case TimeLimitRole: { - auto timeLimit = apiServiceData.serviceInfo.timeLimit; - if (timeLimit == "0") { - return ""; - } - return tr("%1 days").arg(timeLimit); - } - case RegionRole: { - return apiServiceData.serviceInfo.region; - } - case FeaturesRole: { - return apiServiceData.serviceInfo.features; - } case PriceRole: { - auto price = apiServiceData.serviceInfo.price; - if (price == "free") { - return tr("Free"); - } -#if defined(Q_OS_IOS) || defined(MACOS_NE) - return tr("%1 $").arg(price); -#else - return tr("%1 $/month").arg(price); -#endif + return apiServiceData.minPriceLabel; } case EndDateRole: { return QDateTime::fromString(apiServiceData.subscription.endDate, Qt::ISODate).toLocalTime().toString("d MMM yyyy"); } + case TermsOfUseUrlRole: { + return apiServiceData.serviceInfo.termsOfUseUrl; + } + case PrivacyPolicyUrlRole: { + return apiServiceData.serviceInfo.privacyPolicyUrl; + } + case ShowRecommendedRole: { + return serviceType == serviceType::amneziaPremium; + } case OrderRole: { if (serviceType == serviceType::amneziaPremium) { return 0; - } else if (serviceType == serviceType::amneziaTrial) { - return 1; - } else if (serviceType == serviceType::amneziaFree) { - return 2; } + if (serviceType == serviceType::amneziaFree) { + return 1; + } + return QVariant(); } } @@ -155,12 +139,27 @@ void ApiServicesModel::updateModel(const QJsonObject &data) } } + if (!m_services.isEmpty() && m_selectedServiceIndex >= m_services.size()) { + m_selectedServiceIndex = 0; + } + endResetModel(); + + emit serviceSelectionChanged(); } void ApiServicesModel::setServiceIndex(const int index) { m_selectedServiceIndex = index; + emit serviceSelectionChanged(); +} + +ApiServicesModel::ApiServicesData ApiServicesModel::selectedServiceData() const +{ + if (m_services.isEmpty() || m_selectedServiceIndex < 0 || m_selectedServiceIndex >= m_services.size()) { + return {}; + } + return m_services.at(m_selectedServiceIndex); } QJsonObject ApiServicesModel::getSelectedServiceInfo() @@ -217,6 +216,16 @@ QVariant ApiServicesModel::getSelectedServiceData(const QString roleString) return {}; } +int ApiServicesModel::serviceIndexForType(const QString &type) const +{ + for (int serviceIndex = 0; serviceIndex < m_services.size(); ++serviceIndex) { + if (m_services.at(serviceIndex).type == type) { + return serviceIndex; + } + } + return -1; +} + QHash ApiServicesModel::roleNames() const { QHash roles; @@ -224,12 +233,11 @@ QHash ApiServicesModel::roleNames() const roles[CardDescriptionRole] = "cardDescription"; roles[ServiceDescriptionRole] = "serviceDescription"; roles[IsServiceAvailableRole] = "isServiceAvailable"; - roles[SpeedRole] = "speed"; - roles[TimeLimitRole] = "timeLimit"; - roles[RegionRole] = "region"; - roles[FeaturesRole] = "features"; roles[PriceRole] = "price"; roles[EndDateRole] = "endDate"; + roles[TermsOfUseUrlRole] = "termsOfUseUrl"; + roles[PrivacyPolicyUrlRole] = "privacyPolicyUrl"; + roles[ShowRecommendedRole] = "showRecommended"; roles[OrderRole] = "order"; return roles; @@ -243,18 +251,22 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs auto availableCountries = data.value(configKey::availableCountries).toArray(); auto serviceDescription = data.value(configKey::serviceDescription).toObject(); - auto subscriptionObject = data.value(configKey::subscription).toObject(); + auto subscriptionObject = data.value(apiDefs::key::subscription).toObject(); ApiServicesData serviceData; - serviceData.serviceInfo.name = serviceInfo.value(configKey::name).toString(); - serviceData.serviceInfo.price = serviceInfo.value(configKey::price).toString(); - serviceData.serviceInfo.region = serviceInfo.value(configKey::region).toString(); - serviceData.serviceInfo.speed = serviceInfo.value(configKey::speed).toString(); - serviceData.serviceInfo.timeLimit = serviceInfo.value(configKey::timelimit).toString(); + serviceData.serviceInfo.name = serviceDescription.value(configKey::serviceName).toString(); serviceData.serviceInfo.cardDescription = serviceDescription.value(configKey::cardDescription).toString(); serviceData.serviceInfo.description = serviceDescription.value(configKey::description).toString(); - serviceData.serviceInfo.features = serviceDescription.value(configKey::features).toString(); + serviceData.serviceInfo.termsOfUseUrl = serviceDescription.value(apiDefs::key::termsOfUseUrl).toString(); + serviceData.serviceInfo.privacyPolicyUrl = serviceDescription.value(apiDefs::key::privacyPolicyUrl).toString(); + + serviceData.subscriptionPlansJson = serviceDescription.value(configKey::subscriptionPlans).toArray(); + serviceData.benefits = serviceDescription.value(configKey::benefits).toArray(); + + serviceData.minPriceLabel = serviceDescription.value(configKey::minPriceLabel).toString().trimmed(); + + serviceData.supportInfo = data.value(apiDefs::key::supportInfo).toObject(); serviceData.type = serviceType; serviceData.protocol = serviceProtocol; @@ -270,7 +282,7 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs serviceData.serviceInfo.object = serviceInfo; serviceData.availableCountries = availableCountries; - serviceData.subscription.endDate = subscriptionObject.value(configKey::endDate).toString(); + serviceData.subscription.endDate = subscriptionObject.value(apiDefs::key::endDate).toString(); return serviceData; } diff --git a/client/ui/models/api/apiServicesModel.h b/client/ui/models/api/apiServicesModel.h index cee405b39..7bd5492d6 100644 --- a/client/ui/models/api/apiServicesModel.h +++ b/client/ui/models/api/apiServicesModel.h @@ -4,65 +4,23 @@ #include #include #include +#include class ApiServicesModel : public QAbstractListModel { Q_OBJECT public: - enum Roles { - NameRole = Qt::UserRole + 1, - CardDescriptionRole, - ServiceDescriptionRole, - IsServiceAvailableRole, - SpeedRole, - TimeLimitRole, - RegionRole, - FeaturesRole, - PriceRole, - EndDateRole, - OrderRole - }; - - explicit ApiServicesModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - -public slots: - void updateModel(const QJsonObject &data); - - void setServiceIndex(const int index); - - QJsonObject getSelectedServiceInfo(); - QString getSelectedServiceType(); - QString getSelectedServiceProtocol(); - QString getSelectedServiceName(); - QJsonArray getSelectedServiceCountries(); - - QString getCountryCode(); - - QString getStoreEndpoint(); - - QVariant getSelectedServiceData(const QString roleString); - -protected: - QHash roleNames() const override; - -private: struct ServiceInfo { QString name; - QString speed; - QString timeLimit; - QString region; - QString price; QString description; - QString features; QString cardDescription; + QString termsOfUseUrl; + QString privacyPolicyUrl; + QJsonObject object; }; @@ -80,11 +38,64 @@ private: QString storeEndpoint; ServiceInfo serviceInfo; + QJsonObject supportInfo; Subscription subscription; QJsonArray availableCountries; + + QJsonArray subscriptionPlansJson; + QJsonArray benefits; + + QString minPriceLabel; }; + enum Roles { + NameRole = Qt::UserRole + 1, + CardDescriptionRole, + ServiceDescriptionRole, + IsServiceAvailableRole, + PriceRole, + EndDateRole, + TermsOfUseUrlRole, + PrivacyPolicyUrlRole, + ShowRecommendedRole, + OrderRole + }; + + explicit ApiServicesModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + ApiServicesData selectedServiceData() const; + +public slots: + void updateModel(const QJsonObject &data); + + void setServiceIndex(const int index); + + QJsonObject getSelectedServiceInfo(); + QString getSelectedServiceType(); + QString getSelectedServiceProtocol(); + QString getSelectedServiceName(); + QJsonArray getSelectedServiceCountries(); + + QString getCountryCode(); + + QString getStoreEndpoint(); + + QVariant getSelectedServiceData(const QString roleString); + + Q_INVOKABLE int serviceIndexForType(const QString &type) const; + +signals: + void serviceSelectionChanged(); + +protected: + QHash roleNames() const override; + +private: ApiServicesData getApiServicesData(const QJsonObject &data); QString m_countryCode; @@ -93,4 +104,4 @@ private: int m_selectedServiceIndex; }; -#endif // APISERVICESMODEL_H +#endif diff --git a/client/ui/models/api/apiSubscriptionPlansModel.cpp b/client/ui/models/api/apiSubscriptionPlansModel.cpp new file mode 100644 index 000000000..6972f8e0e --- /dev/null +++ b/client/ui/models/api/apiSubscriptionPlansModel.cpp @@ -0,0 +1,131 @@ +#include "apiSubscriptionPlansModel.h" + +#include +#include +#include +#include + +namespace +{ +namespace configKey +{ + constexpr char billingPeriod[] = "billing_period"; + constexpr char priceLabel[] = "price_label"; + constexpr char subtitle[] = "subtitle"; + constexpr char recommended[] = "recommended"; + constexpr char checkoutUrl[] = "checkout_url"; + constexpr char isTrial[] = "is_trial"; + constexpr char serviceProtocol[] = "service_protocol"; + constexpr char storeProductId[] = "store_product_id"; +} +} + +ApiSubscriptionPlansModel::ApiSubscriptionPlansModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int ApiSubscriptionPlansModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_subscriptionPlans.size(); +} + +QVariant ApiSubscriptionPlansModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_subscriptionPlans.size()) { + return {}; + } + const SubscriptionPlanItem &plan = m_subscriptionPlans.at(index.row()); + switch (role) { + case BillingPeriodRole: + return plan.billingPeriod; + case PriceLabelRole: + return plan.priceLabel; + case SubtitleRole: + return plan.subtitle; + case RecommendedRole: + return plan.recommended; + case CheckoutUrlRole: + return plan.checkoutUrl; + case IsTrialRole: + return plan.isTrial; + case ServiceProtocolRole: + return plan.serviceProtocol; + case StoreProductIdRole: + return plan.storeProductId; + default: + return {}; + } +} + +QHash ApiSubscriptionPlansModel::roleNames() const +{ + return { + { BillingPeriodRole, "billingPeriod" }, + { PriceLabelRole, "priceLabel" }, + { SubtitleRole, "subtitle" }, + { RecommendedRole, "recommended" }, + { CheckoutUrlRole, "checkoutUrl" }, + { IsTrialRole, "isTrial" }, + { ServiceProtocolRole, "serviceProtocol" }, + { StoreProductIdRole, "storeProductId" }, + }; +} + +void ApiSubscriptionPlansModel::updateModel(const QJsonArray &arr) +{ + beginResetModel(); + m_subscriptionPlans.clear(); + m_subscriptionPlans.reserve(arr.size()); + for (const QJsonValue &planValue : arr) { + if (!planValue.isObject()) { + continue; + } + const QJsonObject planObject = planValue.toObject(); + SubscriptionPlanItem subscriptionPlan; + subscriptionPlan.billingPeriod = planObject.value(configKey::billingPeriod).toString(); + subscriptionPlan.priceLabel = planObject.value(configKey::priceLabel).toString(); + subscriptionPlan.subtitle = planObject.value(configKey::subtitle).toString(); + subscriptionPlan.recommended = planObject.value(configKey::recommended).toBool(); + subscriptionPlan.checkoutUrl = planObject.value(configKey::checkoutUrl).toString(); + subscriptionPlan.isTrial = planObject.value(configKey::isTrial).toBool(); + subscriptionPlan.serviceProtocol = planObject.value(configKey::serviceProtocol).toString(); + subscriptionPlan.storeProductId = planObject.value(configKey::storeProductId).toString(); + m_subscriptionPlans.append(std::move(subscriptionPlan)); + } + endResetModel(); +} + +void ApiSubscriptionPlansModel::clear() +{ + beginResetModel(); + m_subscriptionPlans.clear(); + endResetModel(); +} + +QVariantMap ApiSubscriptionPlansModel::planAt(int row) const +{ + if (row < 0 || row >= m_subscriptionPlans.size()) { + return {}; + } + const QModelIndex modelIndex = index(row, 0); + QVariantMap planMap; + const QHash roles = roleNames(); + for (auto roleIt = roles.cbegin(); roleIt != roles.cend(); ++roleIt) { + planMap.insert(QString::fromUtf8(roleIt.value()), data(modelIndex, roleIt.key())); + } + return planMap; +} + +int ApiSubscriptionPlansModel::recommendedRowIndex() const +{ + for (int planIndex = 0; planIndex < m_subscriptionPlans.size(); ++planIndex) { + if (m_subscriptionPlans.at(planIndex).recommended) { + return planIndex; + } + } + return 0; +} diff --git a/client/ui/models/api/apiSubscriptionPlansModel.h b/client/ui/models/api/apiSubscriptionPlansModel.h new file mode 100644 index 000000000..3a26f7649 --- /dev/null +++ b/client/ui/models/api/apiSubscriptionPlansModel.h @@ -0,0 +1,53 @@ +#ifndef APISUBSCRIPTIONPLANSMODEL_H +#define APISUBSCRIPTIONPLANSMODEL_H + +#include +#include +#include + +class ApiSubscriptionPlansModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + BillingPeriodRole = Qt::UserRole + 1, + PriceLabelRole, + SubtitleRole, + RecommendedRole, + CheckoutUrlRole, + IsTrialRole, + ServiceProtocolRole, + StoreProductIdRole + }; + Q_ENUM(Roles) + + explicit ApiSubscriptionPlansModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void updateModel(const QJsonArray &arr); + void clear(); + + Q_INVOKABLE QVariantMap planAt(int row) const; + Q_INVOKABLE int recommendedRowIndex() const; + +private: + struct SubscriptionPlanItem + { + QString billingPeriod; + QString priceLabel; + QString subtitle; + bool recommended = false; + QString checkoutUrl; + bool isTrial = false; + QString serviceProtocol; + QString storeProductId; + }; + + QVector m_subscriptionPlans; +}; + +#endif diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index 70d5541cc..eadc12e31 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -180,18 +180,35 @@ QVariant ServersModel::data(const QModelIndex &index, int role) const return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString(); } case IsSubscriptionExpiredRole: { - if (configVersion != apiDefs::ConfigSource::AmneziaGateway) return false; - QString endDate = apiConfig.value(apiDefs::key::subscriptionEndDate).toString(); - if (endDate.isEmpty()) return false; + if (configVersion != apiDefs::ConfigSource::AmneziaGateway) { + return false; + } + if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) { + return false; + } + if (apiConfig.value(apiDefs::key::subscriptionExpiredByServer).toBool(false)) { + return true; + } + const QString endDate = + apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString(); + if (endDate.isEmpty()) { + return false; + } return apiUtils::isSubscriptionExpired(endDate); } case IsSubscriptionExpiringSoonRole: { - if (configVersion != apiDefs::ConfigSource::AmneziaGateway) return false; - QString endDate = apiConfig.value(apiDefs::key::subscriptionEndDate).toString(); - if (endDate.isEmpty()) return false; - if (apiUtils::isSubscriptionExpired(endDate)) return false; - QDateTime endDateTime = QDateTime::fromString(endDate, Qt::ISODateWithMs); - return endDateTime <= QDateTime::currentDateTimeUtc().addDays(10); + if (configVersion != apiDefs::ConfigSource::AmneziaGateway) { + return false; + } + if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) { + return false; + } + const QString endDate = + apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString(); + if (endDate.isEmpty()) { + return false; + } + return apiUtils::isSubscriptionExpiringSoon(endDate); } } @@ -744,21 +761,21 @@ bool ServersModel::isServerFromApiAlreadyExists(const QString &userCountryCode, return false; } -bool ServersModel::hasServerWithVpnKey(const QString &vpnKey) const +int ServersModel::indexOfServerWithVpnKey(const QString &vpnKey) const { const QString normalizedInput = normalizeVpnKey(vpnKey); if (normalizedInput.isEmpty()) { - return false; + return -1; } - for (const auto &server : std::as_const(m_servers)) { - const auto apiConfig = server.toObject().value(configKey::apiConfig).toObject(); + for (int i = 0; i < m_servers.size(); ++i) { + const auto apiConfig = m_servers.at(i).toObject().value(configKey::apiConfig).toObject(); const QString existingKey = normalizeVpnKey(apiConfig.value(apiDefs::key::vpnKey).toString()); if (!existingKey.isEmpty() && existingKey == normalizedInput) { - return true; + return i; } } - return false; + return -1; } bool ServersModel::serverHasInstalledContainers(const int serverIndex) const diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index 6aba7d37c..5264b35ba 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -143,7 +143,7 @@ public slots: bool isServerFromApiAlreadyExists(const quint16 crc); bool isServerFromApiAlreadyExists(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol); - bool hasServerWithVpnKey(const QString &vpnKey) const; + int indexOfServerWithVpnKey(const QString &vpnKey) const; QVariant getDefaultServerData(const QString roleString); diff --git a/client/ui/qml/Components/BenefitRow.qml b/client/ui/qml/Components/BenefitRow.qml new file mode 100644 index 000000000..07b547f12 --- /dev/null +++ b/client/ui/qml/Components/BenefitRow.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2/TextTypes" + +RowLayout { + id: root + + property string iconSource: "" + property string titleText: "" + property string bodyText: "" + property bool accent: false + + spacing: 12 + + Image { + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: 22 + Layout.preferredHeight: 22 + source: root.iconSource + fillMode: Image.PreserveAspectFit + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + LabelTextType { + Layout.fillWidth: true + text: root.titleText + color: AmneziaStyle.color.paleGray + font.pixelSize: 16 + font.weight: Font.DemiBold + wrapMode: Text.Wrap + } + + Item { + Layout.fillWidth: true + implicitHeight: bodyLabel.implicitHeight + + LabelTextType { + id: bodyLabel + width: parent.width + text: root.bodyText + color: root.accent ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray + font.pixelSize: 14 + wrapMode: Text.Wrap + } + + MouseArea { + anchors.fill: bodyLabel + visible: root.accent && root.bodyText.length > 0 + cursorShape: Qt.PointingHandCursor + onClicked: { + var t = root.bodyText.trim() + if (t.startsWith("@")) { + Qt.openUrlExternally("https://t.me/" + t.substring(1)) + } + } + } + } + } +} diff --git a/client/ui/qml/Components/BenefitsPanel.qml b/client/ui/qml/Components/BenefitsPanel.qml new file mode 100644 index 000000000..bb1d3a23e --- /dev/null +++ b/client/ui/qml/Components/BenefitsPanel.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Layouts + +import "." + +import Style 1.0 + +Rectangle { + id: root + + property var benefitsModel: null + + visible: benefitsModel && benefitsModel.rowCount() > 0 + + radius: 16 + color: AmneziaStyle.color.benefitsPanelBackground + implicitHeight: inner.implicitHeight + 24 + + ColumnLayout { + id: inner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 20 + + Repeater { + model: benefitsModel + + delegate: BenefitRow { + Layout.fillWidth: true + iconSource: model.icon + titleText: model.title + bodyText: model.body + accent: !!model.accent + } + } + } +} diff --git a/client/ui/qml/Components/ServersListView.qml b/client/ui/qml/Components/ServersListView.qml index 69c6acada..54ef0c911 100644 --- a/client/ui/qml/Components/ServersListView.qml +++ b/client/ui/qml/Components/ServersListView.qml @@ -67,7 +67,12 @@ ListViewType { Layout.fillWidth: true text: name - descriptionText: serverDescription + descriptionText: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) + ? (isSubscriptionExpired ? qsTr("Subscription expired. Please renew.") : qsTr("Subscription expiring soon.")) + : serverDescription + descriptionColor: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) + ? (isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot) + : AmneziaStyle.color.mutedGray checked: index === root.selectedIndex checkable: !ConnectionController.isConnected @@ -126,18 +131,6 @@ ListViewType { } } - CaptionTextType { - visible: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) - - Layout.fillWidth: true - Layout.leftMargin: 64 - Layout.bottomMargin: 8 - - text: isSubscriptionExpired ? qsTr("Subscription expired. Please renew.") : qsTr("Subscription expiring soon.") - color: isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot - wrapMode: Text.WordWrap - } - DividerType { Layout.fillWidth: true Layout.leftMargin: 0 diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml index 8be6b805a..0540dbfa7 100644 --- a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -12,6 +12,13 @@ import "../Controls2/TextTypes" DrawerType2 { id: root + property bool isRenewalActionAvailable: false + + onOpened: { + isRenewalActionAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") + && !ApiAccountInfoModel.data("isInAppPurchase") + } + expandedStateContent: ColumnLayout { id: content @@ -43,6 +50,8 @@ DrawerType2 { } ParagraphTextType { + visible: root.isRenewalActionAvailable + Layout.fillWidth: true Layout.topMargin: 8 Layout.rightMargin: 16 @@ -53,6 +62,8 @@ DrawerType2 { } BasicButtonType { + visible: root.isRenewalActionAvailable + Layout.fillWidth: true Layout.topMargin: 16 Layout.rightMargin: 16 diff --git a/client/ui/qml/Components/SubscriptionPlanCard.qml b/client/ui/qml/Components/SubscriptionPlanCard.qml new file mode 100644 index 000000000..f69ece34c --- /dev/null +++ b/client/ui/qml/Components/SubscriptionPlanCard.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2/TextTypes" + +Rectangle { + id: root + + property bool selected: false + property string billingPeriod: "" + property string priceLabel: "" + property string subtitle: "" + property bool showRecommendedBadge: false + property string recommendedText: "Recommended" + + signal selectRequested + + implicitHeight: cardLayout.implicitHeight + 28 + radius: 16 + color: AmneziaStyle.color.transparent + border.width: selected ? 2 : 1 + border.color: selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.charcoalGray + + ColumnLayout { + id: cardLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + spacing: 8 + + RowLayout { + Layout.fillWidth: true + + LabelTextType { + Layout.fillWidth: true + text: root.billingPeriod + color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray + font.pixelSize: 17 + font.weight: Font.DemiBold + wrapMode: Text.Wrap + } + + LabelTextType { + text: root.priceLabel + color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray + font.pixelSize: 17 + font.weight: Font.DemiBold + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.subtitle.length > 0 || root.showRecommendedBadge + + LabelTextType { + Layout.fillWidth: true + text: root.subtitle + color: AmneziaStyle.color.mutedGray + font.pixelSize: 13 + wrapMode: Text.Wrap + } + + Rectangle { + visible: root.showRecommendedBadge + Layout.alignment: Qt.AlignVCenter + radius: 10 + color: AmneziaStyle.color.softViolet + implicitHeight: recLabel.implicitHeight + 8 + implicitWidth: recLabel.implicitWidth + 16 + + LabelTextType { + id: recLabel + + anchors.centerIn: parent + text: root.recommendedText + color: AmneziaStyle.color.midnightBlack + font.pixelSize: 11 + font.weight: Font.Medium + } + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.selectRequested() + } +} diff --git a/client/ui/qml/Components/TermsAndPrivacyText.qml b/client/ui/qml/Components/TermsAndPrivacyText.qml new file mode 100644 index 000000000..5eb4a142a --- /dev/null +++ b/client/ui/qml/Components/TermsAndPrivacyText.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2/TextTypes" + +ParagraphTextType { + id: root + + property string termsUrl: "" + property string privacyUrl: "" + + Layout.fillWidth: true + + horizontalAlignment: Text.AlignHCenter + textFormat: Text.RichText + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + + text: qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") + .arg(root.termsUrl) + .arg(root.privacyUrl) + .arg(AmneziaStyle.color.goldenApricotString) + + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } +} diff --git a/client/ui/qml/Controls2/CardWithIconsType.qml b/client/ui/qml/Controls2/CardWithIconsType.qml index 02d033d26..52cd31e0f 100644 --- a/client/ui/qml/Controls2/CardWithIconsType.qml +++ b/client/ui/qml/Controls2/CardWithIconsType.qml @@ -13,6 +13,11 @@ Button { property string bodyText property string footerText + property color headerTextColor: AmneziaStyle.color.paleGray + property color bodyTextColor: AmneziaStyle.color.mutedGray + property bool showRecommendedBadge: false + property string recommendedText: "" + property string hoveredColor: AmneziaStyle.color.slateGray property string defaultColor: AmneziaStyle.color.onyxBlack @@ -28,6 +33,7 @@ Button { property alias focusItem: rightImage hoverEnabled: true + clip: false background: Rectangle { id: backgroundRect @@ -43,104 +49,151 @@ Button { } contentItem: Item { + id: contentRoot + anchors.left: parent.left anchors.right: parent.right - implicitHeight: content.implicitHeight + readonly property bool badgeVisible: root.showRecommendedBadge && root.recommendedText !== "" - RowLayout { - id: content + implicitHeight: layoutCol.implicitHeight - anchors.fill: parent + ColumnLayout { + id: layoutCol - Image { - id: leftImage - source: leftImageSource + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 - visible: leftImageSource !== "" + Item { + id: badgeTopSpacer - Layout.alignment: Qt.AlignLeft | Qt.AlignTop - Layout.topMargin: 24 - Layout.bottomMargin: 24 - Layout.leftMargin: 24 - } - - ColumnLayout { - - ListItemTitleType { - text: root.headerText - visible: text !== "" - - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.topMargin: 16 - Layout.bottomMargin: root.bodyText !== "" ? 0 : 16 - - opacity: root.textOpacity - } - - CaptionTextType { - text: root.bodyText - visible: text !== "" - - color: AmneziaStyle.color.mutedGray - textFormat: Text.RichText - - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.bottomMargin: root.footerText !== "" ? 0 : 16 - - opacity: root.textOpacity - } - - ButtonTextType { - text: root.footerText - visible: text !== "" - - color: AmneziaStyle.color.mutedGray - - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.topMargin: 16 - Layout.bottomMargin: 16 - - opacity: root.textOpacity - } - } - - ImageButtonType { - id: rightImage - - implicitWidth: 40 - implicitHeight: 40 - - hoverEnabled: false - image: rightImageSource - imageColor: rightImageColor - visible: rightImageSource ? true : false - - Layout.alignment: Qt.AlignRight | Qt.AlignTop - Layout.topMargin: 16 - Layout.bottomMargin: 16 - Layout.rightMargin: 16 + Layout.fillWidth: true + Layout.preferredHeight: contentRoot.badgeVisible ? (recBadge.height / 2 + 8) : 0 Rectangle { - id: rightImageBackground + id: recBadge - anchors.fill: parent - radius: 12 - color: "transparent" + visible: contentRoot.badgeVisible + z: 2 - Behavior on color { - PropertyAnimation { duration: 200 } + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.verticalCenter: parent.top + + radius: 10 + color: AmneziaStyle.color.softViolet + implicitHeight: recLabel.implicitHeight + 8 + implicitWidth: recLabel.implicitWidth + 16 + + width: implicitWidth + height: implicitHeight + + BadgeTextType { + id: recLabel + + anchors.centerIn: parent + text: root.recommendedText + } + } + } + + RowLayout { + id: content + + Layout.fillWidth: true + + Image { + id: leftImage + source: leftImageSource + + visible: leftImageSource !== "" + + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.topMargin: 24 + Layout.bottomMargin: 24 + Layout.leftMargin: 24 + } + + ColumnLayout { + + ListItemTitleType { + text: root.headerText + visible: text !== "" + + color: root.headerTextColor + + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.topMargin: contentRoot.badgeVisible ? 0 : 16 + Layout.bottomMargin: root.bodyText !== "" ? 0 : 16 + + opacity: root.textOpacity + } + + CaptionTextType { + text: root.bodyText + visible: text !== "" + + color: root.bodyTextColor + textFormat: Text.RichText + + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.bottomMargin: root.footerText !== "" ? 0 : 8 + + opacity: root.textOpacity + } + + ButtonTextType { + text: root.footerText + visible: text !== "" + + color: AmneziaStyle.color.mutedGray + + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 16 + + opacity: root.textOpacity } } - onClicked: { - root.clicked() + ImageButtonType { + id: rightImage + + implicitWidth: 40 + implicitHeight: 40 + + hoverEnabled: false + image: rightImageSource + imageColor: rightImageColor + visible: rightImageSource ? true : false + + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.topMargin: 16 + Layout.bottomMargin: 16 + Layout.rightMargin: 16 + + Rectangle { + id: rightImageBackground + + anchors.fill: parent + radius: 12 + color: "transparent" + + Behavior on color { + PropertyAnimation { duration: 200 } + } + } + + onClicked: { + root.clicked() + } } } } diff --git a/client/ui/qml/Controls2/TextTypes/BadgeTextType.qml b/client/ui/qml/Controls2/TextTypes/BadgeTextType.qml new file mode 100644 index 000000000..86327f3d0 --- /dev/null +++ b/client/ui/qml/Controls2/TextTypes/BadgeTextType.qml @@ -0,0 +1,15 @@ +import QtQuick + +import Style 1.0 + +Text { + lineHeight: 10 + LanguageModel.getLineHeightAppend() + lineHeightMode: Text.FixedHeight + + color: AmneziaStyle.color.midnightBlack + font.pixelSize: 11 + font.weight: Font.Medium + font.family: "PT Root UI VF" + + wrapMode: Text.NoWrap +} diff --git a/client/ui/qml/Modules/Style/AmneziaStyle.qml b/client/ui/qml/Modules/Style/AmneziaStyle.qml index 20b563360..6c81dc9af 100644 --- a/client/ui/qml/Modules/Style/AmneziaStyle.qml +++ b/client/ui/qml/Modules/Style/AmneziaStyle.qml @@ -12,13 +12,17 @@ QtObject { readonly property color slateGray: '#2C2D30' readonly property color onyxBlack: '#1C1D21' readonly property color midnightBlack: '#0E0E11' - readonly property color goldenApricot: '#FBB26A' + readonly property color goldenApricot: goldenApricotString + readonly property color benefitsPanelBackground: '#1C1C1E' + readonly property color softViolet: '#A87BE2' readonly property color burntOrange: '#A85809' readonly property color mutedBrown: '#84603D' readonly property color richBrown: '#633303' readonly property color deepBrown: '#402102' readonly property color vibrantRed: '#EB5757' readonly property color darkCharcoal: '#261E1A' + readonly property color pearlGray: '#EAEAEC' + readonly property color sheerWhite: Qt.rgba(1, 1, 1, 0.12) readonly property color translucentWhite: Qt.rgba(1, 1, 1, 0.08) readonly property color barelyTranslucentWhite: Qt.rgba(1, 1, 1, 0.05) @@ -26,9 +30,10 @@ QtObject { readonly property color softGoldenApricot: Qt.rgba(251/255, 178/255, 106/255, 0.3) readonly property color mistyGray: Qt.rgba(215/255, 216/255, 219/255, 0.8) readonly property color cloudyGray: Qt.rgba(215/255, 216/255, 219/255, 0.65) - readonly property color pearlGray: '#EAEAEC' readonly property color translucentRichBrown: Qt.rgba(99/255, 51/255, 3/255, 0.26) readonly property color translucentSlateGray: Qt.rgba(85/255, 86/255, 92/255, 0.13) readonly property color translucentOnyxBlack: Qt.rgba(28/255, 29/255, 33/255, 0.13) + + readonly property string goldenApricotString: '#FBB26A' } } diff --git a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml index d7aded663..ca0976188 100644 --- a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml +++ b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml @@ -20,9 +20,14 @@ PageType { property var processedServer property bool subscriptionExpired: false property bool subscriptionExpiringSoon: false + property bool isSubscriptionRenewalAvailable: false + property bool isInAppPurchase: false + function updateSubscriptionState() { root.subscriptionExpired = ServersModel.getProcessedServerData("isSubscriptionExpired") root.subscriptionExpiringSoon = ServersModel.getProcessedServerData("isSubscriptionExpiringSoon") + root.isSubscriptionRenewalAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") + root.isInAppPurchase = ApiAccountInfoModel.data("isInAppPurchase") } Component.onCompleted: { @@ -38,6 +43,14 @@ PageType { } } + Connections { + target: ApiAccountInfoModel + + function onModelReset() { + root.updateSubscriptionState() + } + } + SortFilterProxyModel { id: proxyServersModel objectName: "proxyServersModel" @@ -87,7 +100,7 @@ PageType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.bottomMargin: 4 + Layout.bottomMargin: root.subscriptionExpired || root.subscriptionExpiringSoon ? 0 : 4 actionButtonImage: "qrc:/images/controls/settings.svg" @@ -105,26 +118,27 @@ PageType { } } - CaptionTextType { + ParagraphTextType { visible: root.subscriptionExpired || root.subscriptionExpiringSoon Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: 4 + Layout.topMargin: 12 text: root.subscriptionExpired ? qsTr("Subscription expired") : qsTr("Subscription expiring soon") color: root.subscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot } BasicButtonType { - visible: root.subscriptionExpired || root.subscriptionExpiringSoon + visible: (root.subscriptionExpired || root.subscriptionExpiringSoon) + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: 8 - Layout.bottomMargin: 4 + Layout.topMargin: 28 + Layout.bottomMargin: 0 defaultColor: AmneziaStyle.color.paleGray hoveredColor: AmneziaStyle.color.lightGray @@ -138,11 +152,11 @@ PageType { } } - CaptionTextType { + ParagraphTextType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: (root.subscriptionExpired || root.subscriptionExpiringSoon) ? 8 : 4 + Layout.topMargin: (root.subscriptionExpired || root.subscriptionExpiringSoon) ? 12 : 4 Layout.bottomMargin: 8 text: qsTr("Location for connection") diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 7e44138a5..d711ab923 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Dialogs -import Qt5Compat.GraphicalEffects import SortFilterProxyModel 0.2 @@ -55,10 +54,14 @@ PageType { property bool isSubscriptionExpired: false property bool isSubscriptionExpiringSoon: false + property bool isSubscriptionRenewalAvailable: false + property bool isInAppPurchase: false function updateSubscriptionState() { root.isSubscriptionExpired = ApiAccountInfoModel.data("isSubscriptionExpired") root.isSubscriptionExpiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon") + root.isSubscriptionRenewalAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") + root.isInAppPurchase = ApiAccountInfoModel.data("isInAppPurchase") } Component.onCompleted: { @@ -124,7 +127,7 @@ PageType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.bottomMargin: 10 + Layout.bottomMargin: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon ? 0 : 10 actionButtonImage: "qrc:/images/controls/edit-3.svg" @@ -135,13 +138,13 @@ PageType { } } - Text { + ParagraphTextType { visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: 4 + Layout.topMargin: 12 text: root.isSubscriptionExpired ? qsTr("Subscription expired") @@ -150,10 +153,6 @@ PageType { color: root.isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot - - font.pixelSize: 14 - font.weight: Font.Medium - wrapMode: Text.WordWrap } ParagraphTextType { @@ -170,7 +169,8 @@ PageType { } BasicButtonType { - visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon + visible: (root.isSubscriptionExpired || root.isSubscriptionExpiringSoon) + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase Layout.fillWidth: true Layout.leftMargin: 16 @@ -226,52 +226,33 @@ PageType { readonly property bool isVisibleForAmneziaFree: ApiAccountInfoModel.data("isComponentVisible") - Item { + BasicButtonType { visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase - Layout.fillWidth: true - implicitHeight: renewRow.implicitHeight + 32 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 16 + Layout.bottomMargin: 16 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: ApiSettingsController.getRenewalLink() - } + implicitHeight: 25 - Row { - id: renewRow - anchors.centerIn: parent - spacing: 12 + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.goldenApricot + leftImageSource: "qrc:/images/controls/refresh-cw.svg" + leftImageColor: AmneziaStyle.color.goldenApricot - Item { - width: renewIcon.implicitWidth - height: renewIcon.implicitHeight - anchors.verticalCenter: parent.verticalCenter + text: qsTr("Renew subscription") - Image { - id: renewIcon - source: "qrc:/images/controls/refresh-cw.svg" - } - - ColorOverlay { - anchors.fill: renewIcon - source: renewIcon - color: AmneziaStyle.color.goldenApricot - } - } - - Text { - text: qsTr("Renew subscription") - color: AmneziaStyle.color.goldenApricot - font.pixelSize: 18 - font.weight: Font.Medium - anchors.verticalCenter: parent.verticalCenter - } + clickedFunc: function() { + ApiSettingsController.getRenewalLink() } } DividerType { visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase } SwitcherType { diff --git a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml new file mode 100644 index 000000000..507e0d621 --- /dev/null +++ b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml @@ -0,0 +1,140 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + property string freeHeaderName: "" + property string freeHeaderDescription: "" + + function syncFromModel() { + root.freeHeaderName = String(ApiServicesModel.getSelectedServiceData("name")) + root.freeHeaderDescription = String(ApiServicesModel.getSelectedServiceData("serviceDescription")) + } + + Component.onCompleted: syncFromModel() + + BackButtonType { + id: backButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + + onFocusChanged: { + if (activeFocus) { + flick.contentY = 0 + } + } + } + + FlickableType { + id: flick + + anchors.top: backButton.bottom + anchors.bottom: continueButton.top + anchors.left: parent.left + anchors.right: parent.right + + contentHeight: scrollColumn.implicitHeight + 24 + + ColumnLayout { + id: scrollColumn + + width: flick.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: root.freeHeaderName + descriptionText: root.freeHeaderDescription + } + + LabelTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 12 + + text: qsTr("Free features") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 13 + } + + BenefitsPanel { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + benefitsModel: ApiBenefitsModel + } + + TermsAndPrivacyText { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + + visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) + + termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")) + privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")) + } + + TermsAndPrivacyText { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) + + termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + privacyUrl: LanguageModel.getCurrentSiteUrl("policy") + } + } + } + + BasicButtonType { + id: continueButton + + z: 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin + + text: qsTr("Continue") + + clickedFunc: function() { + PageController.showBusyIndicator(true) + var result = ApiConfigsController.importService() + PageController.showBusyIndicator(false) + + if (!result) { + var endpoint = ApiServicesModel.getStoreEndpoint() + Qt.openUrlExternally(endpoint) + PageController.closePage() + PageController.closePage() + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml new file mode 100644 index 000000000..b2fcce852 --- /dev/null +++ b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml @@ -0,0 +1,198 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" +import PageEnum 1.0 + +PageType { + id: root + + property int selectedPlanIndex: 0 + property string premiumHeaderName: "" + property string premiumHeaderDescription: "" + + readonly property var currentPlan: ApiSubscriptionPlansModel.planAt(selectedPlanIndex) + + function syncFromModel() { + root.selectedPlanIndex = ApiSubscriptionPlansModel.recommendedRowIndex() + + root.premiumHeaderName = String(ApiServicesModel.getSelectedServiceData("name")) + root.premiumHeaderDescription = String(ApiServicesModel.getSelectedServiceData("serviceDescription")) + } + + Component.onCompleted: syncFromModel() + + BackButtonType { + id: backButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + + onFocusChanged: { + if (activeFocus) { + flick.contentY = 0 + } + } + } + + FlickableType { + id: flick + + anchors.top: backButton.bottom + anchors.bottom: continueButton.top + anchors.left: parent.left + anchors.right: parent.right + + contentHeight: scrollColumn.implicitHeight + 24 + + ColumnLayout { + id: scrollColumn + + width: flick.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: root.premiumHeaderName + descriptionText: root.premiumHeaderDescription + } + + Repeater { + model: ApiSubscriptionPlansModel + + delegate: SubscriptionPlanCard { + required property int index + required property var model + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: index === ApiSubscriptionPlansModel.rowCount() - 1 ? 24 : 12 + + selected: root.selectedPlanIndex === index + billingPeriod: String(model.billingPeriod) + priceLabel: String(model.priceLabel) + subtitle: String(model.subtitle) + showRecommendedBadge: !!model.recommended + recommendedText: qsTr("Recommended") + + onSelectRequested: root.selectedPlanIndex = index + } + } + + LabelTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 12 + + text: qsTr("Premium features") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 13 + } + + BenefitsPanel { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + benefitsModel: ApiBenefitsModel + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + visible: Qt.platform.os === "ios" || IsMacOsNeBuild + spacing: 16 + + ParagraphTextType { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + + text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.") + } + + TermsAndPrivacyText { + termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + privacyUrl: LanguageModel.getCurrentSiteUrl("policy") + } + } + + TermsAndPrivacyText { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) + + termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")) + privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")) + } + } + } + + BasicButtonType { + id: continueButton + + z: 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin + + text: { + var plan = root.currentPlan + if (!plan) { + return qsTr("Continue") + } + return qsTr("Subscribe — %1 for %2").arg(String(plan.billingPeriod)).arg(String(plan.priceLabel)) + } + + clickedFunc: function() { + var plan = root.currentPlan + if (!plan) { + return + } + if (plan.isTrial) { + PageController.goToPage(PageEnum.PageSetupWizardApiTrialEmail) + return + } + if (Qt.platform.os === "ios" || IsMacOsNeBuild) { + PageController.showBusyIndicator(true) + var storeId = plan.storeProductId !== undefined ? String(plan.storeProductId) : "" + ApiConfigsController.importPremiumFromAppStore(storeId) + PageController.showBusyIndicator(false) + return + } + if (plan.checkoutUrl) { + Qt.openUrlExternally(plan.checkoutUrl) + PageController.closePage() + PageController.closePage() + return + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml deleted file mode 100644 index 24308a126..000000000 --- a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml +++ /dev/null @@ -1,226 +0,0 @@ -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" -import "../Components" - -PageType { - id: root - - BackButtonType { - id: backButton - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: 20 + SettingsController.safeAreaTopMargin - - onFocusChanged: { - if (this.activeFocus) { - listView.positionViewAtBeginning() - } - } - } - - ListViewType { - id: listView - - anchors.top: backButton.bottom - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.left: parent.left - - header: ColumnLayout { - width: listView.width - - BaseHeaderType { - Layout.fillWidth: true - Layout.topMargin: 8 - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.bottomMargin: 32 - - headerText: ApiServicesModel.getSelectedServiceData("name") - descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription") - } - } - - model: inputFields - spacing: 0 - - delegate: ColumnLayout { - width: listView.width - - LabelWithImageType { - Layout.fillWidth: true - Layout.margins: 16 - - imageSource: imagePath - leftText: lText - rightText: rText - - visible: isVisible - } - } - - footer: ColumnLayout { - width: listView.width - - spacing: 0 - - ParagraphTextType { - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - - onLinkActivated: function(link) { - Qt.openUrlExternally(link) - } - textFormat: Text.RichText - text: { - var text = ApiServicesModel.getSelectedServiceData("features") - return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } - } - - ParagraphTextType { - Layout.fillWidth: true - Layout.topMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - - visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium" - - horizontalAlignment: Text.AlignHCenter - textFormat: Text.PlainText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.") - } - - BasicButtonType { - id: continueButton - - Layout.fillWidth: true - Layout.topMargin: 32 - Layout.bottomMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - - text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : (ApiServicesModel.getSelectedServiceType() === "amnezia-trial" ? qsTr("Try Trial") : qsTr("Connect")) - - clickedFunc: function() { - PageController.showBusyIndicator(true) - var result = ApiConfigsController.importService() - PageController.showBusyIndicator(false) - - if (!result) { - var endpoint = ApiServicesModel.getStoreEndpoint() - Qt.openUrlExternally(endpoint) - PageController.closePage() - PageController.closePage() - } - } - } - - ParagraphTextType { - Layout.fillWidth: true - Layout.topMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - Layout.bottomMargin: 32 - - visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium" - - horizontalAlignment: Text.AlignHCenter - textFormat: Text.RichText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: { - var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" - var privacyUrl = LanguageModel.getCurrentSiteUrl("policy") - return qsTr("By continuing, you agree to the Terms of Use and Privacy Policy").arg(termsUrl).arg(privacyUrl) - } - - onLinkActivated: function(link) { - Qt.openUrlExternally(link) - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } - } - } - } - - property list inputFields: [ - region, - price, - timeLimit, - speed, - features - ] - - QtObject { - id: region - - readonly property string imagePath: "qrc:/images/controls/map-pin.svg" - readonly property string lText: qsTr("For the region") - readonly property string rText: ApiServicesModel.getSelectedServiceData("region") - property bool isVisible: true - } - - QtObject { - id: price - - readonly property string imagePath: "qrc:/images/controls/tag.svg" - readonly property string lText: qsTr("Price") - readonly property string rText: ApiServicesModel.getSelectedServiceData("price") - property bool isVisible: true - } - - QtObject { - id: timeLimit - - readonly property string imagePath: "qrc:/images/controls/history.svg" - readonly property string lText: qsTr("Work period") - readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit") - property bool isVisible: rText !== "" - } - - QtObject { - id: speed - - readonly property string imagePath: "qrc:/images/controls/gauge.svg" - readonly property string lText: qsTr("Speed") - readonly property string rText: ApiServicesModel.getSelectedServiceData("speed") - property bool isVisible: true - } - - QtObject { - id: features - - readonly property string imagePath: "qrc:/images/controls/info.svg" - readonly property string lText: qsTr("Features") - readonly property string rText: "" - property bool isVisible: true - } -} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml b/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml index edf361f77..6146a697d 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml @@ -84,12 +84,19 @@ PageType { bodyText: cardDescription footerText: price + showRecommendedBadge: showRecommended && isServiceAvailable + recommendedText: qsTr("Recommended") + rightImageSource: "qrc:/images/controls/chevron-right.svg" onClicked: { if (isServiceAvailable) { ApiServicesModel.setServiceIndex(proxyApiServicesModel.mapToSource(index)) - PageController.goToPage(PageEnum.PageSetupWizardApiServiceInfo) + if (ApiServicesModel.getSelectedServiceType() === "amnezia-premium") { + PageController.goToPage(PageEnum.PageSetupWizardApiPremiumInfo) + } else { + PageController.goToPage(PageEnum.PageSetupWizardApiFreeInfo) + } } } diff --git a/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml new file mode 100644 index 000000000..a23eff4bc --- /dev/null +++ b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml @@ -0,0 +1,138 @@ +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 "../Components" + +PageType { + id: root + property string trialEmailErrorMessage: "" + + Connections { + target: ApiConfigsController + + function onTrialEmailError(message) { + root.trialEmailErrorMessage = message + emailField.errorText = message + } + } + + BackButtonType { + id: backButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + + onFocusChanged: { + if (activeFocus) { + flick.contentY = 0 + } + } + } + + FlickableType { + id: flick + + anchors.top: backButton.bottom + anchors.bottom: continueButton.top + anchors.left: parent.left + anchors.right: parent.right + + contentHeight: scrollColumn.implicitHeight + 24 + + ColumnLayout { + id: scrollColumn + + width: flick.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: qsTr("Create an account") + descriptionText: qsTr("To manage your subscription") + } + + TextFieldWithHeaderType { + id: emailField + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: qsTr("Email") + textField.placeholderText: qsTr("Email") + textField.inputMethodHints: Qt.ImhEmailCharactersOnly + + Connections { + target: emailField.textField + + function onTextChanged() { + if (root.trialEmailErrorMessage !== "") { + root.trialEmailErrorMessage = "" + emailField.errorText = "" + } + } + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + wrapMode: Text.WordWrap + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + text: qsTr("We will create an account for your trial subscription and send important subscription updates to this email.") + } + } + } + + BasicButtonType { + id: continueButton + + z: 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin + + text: qsTr("Continue") + + clickedFunc: function() { + root.trialEmailErrorMessage = "" + emailField.errorText = "" + + var raw = emailField.textField.text.trim() + if (raw.length === 0 || raw.indexOf("@") < 0) { + PageController.showNotificationMessage(qsTr("Enter a valid email address")) + return + } + PageController.showBusyIndicator(true) + var ok = ApiConfigsController.importTrialFromGateway(raw) + PageController.showBusyIndicator(false) + if (ok) { + PageController.closePage() + PageController.closePage() + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml index 160177b6c..061ef65a8 100644 --- a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml +++ b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml @@ -222,6 +222,9 @@ PageType { headerText: title bodyText: description + showRecommendedBadge: featuredAmneziaConnection + recommendedText: featuredAmneziaConnection ? qsTr("Recommended") : "" + rightImageSource: "qrc:/images/controls/chevron-right.svg" leftImageSource: imageSource @@ -275,8 +278,9 @@ PageType { id: amneziaVpn property string title: qsTr("VPN by Amnezia") - property string description: qsTr("Connect to classic paid and free VPN services from Amnezia") + property string description: qsTr("The easiest way to connect to VPN") property string imageSource: "qrc:/images/controls/amnezia.svg" + property bool featuredAmneziaConnection: true property bool isVisible: true property var handler: function() { PageController.showBusyIndicator(true) @@ -291,6 +295,7 @@ PageType { QtObject { id: selfHostVpn + property bool featuredAmneziaConnection: false property string title: qsTr("Self-hosted VPN") property string description: qsTr("Configure Amnezia VPN on your own server") property string imageSource: "qrc:/images/controls/server.svg" @@ -303,6 +308,7 @@ PageType { QtObject { id: backupRestore + property bool featuredAmneziaConnection: false property string title: qsTr("Restore from backup") property string description: qsTr("") property string imageSource: "qrc:/images/controls/archive-restore.svg" @@ -321,6 +327,7 @@ PageType { QtObject { id: fileOpen + property bool featuredAmneziaConnection: false property string title: qsTr("File with connection settings") property string description: qsTr("") property string imageSource: "qrc:/images/controls/folder-search-2.svg" @@ -340,6 +347,7 @@ PageType { QtObject { id: qrScan + property bool featuredAmneziaConnection: false property string title: qsTr("QR code") property string description: qsTr("") property string imageSource: "qrc:/images/controls/scan-line.svg" @@ -355,13 +363,14 @@ PageType { QtObject { id: restorePurchases + property bool featuredAmneziaConnection: false property string title: qsTr("Restore purchases") property string description: qsTr("") property string imageSource: "qrc:/images/controls/refresh-cw.svg" property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild property var handler: function() { PageController.showBusyIndicator(true) - ApiConfigsController.restoreSerivceFromAppStore() + ApiConfigsController.restoreServiceFromAppStore() PageController.showBusyIndicator(false) } } @@ -369,6 +378,7 @@ PageType { QtObject { id: siteLink + property bool featuredAmneziaConnection: false property string title: qsTr("I have nothing") property string description: qsTr("") property string imageSource: "qrc:/images/controls/help-circle.svg" diff --git a/client/ui/qml/Pages2/PageStart.qml b/client/ui/qml/Pages2/PageStart.qml index e731704df..51608e0be 100644 --- a/client/ui/qml/Pages2/PageStart.qml +++ b/client/ui/qml/Pages2/PageStart.qml @@ -225,9 +225,13 @@ PageType { Connections { target: ApiConfigsController - function onInstallServerFromApiFinished(message) { + function onInstallServerFromApiFinished(message, preferredDefaultIndex) { if (!ConnectionController.isConnected) { - ServersModel.setDefaultServerIndex(ServersModel.getServersCount() - 1); + if (preferredDefaultIndex !== undefined && preferredDefaultIndex >= 0) { + ServersModel.setDefaultServerIndex(preferredDefaultIndex) + } else { + ServersModel.setDefaultServerIndex(ServersModel.getServersCount() - 1) + } ServersModel.processedIndex = ServersModel.defaultIndex }