mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-08 14:33:23 +00:00
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 <yyy@amnezia.org>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -3,11 +3,33 @@
|
||||
#include <QDateTime>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
|
||||
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<QSslError> &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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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<ClientManagementModel> m_clientManagementModel;
|
||||
|
||||
QSharedPointer<ApiServicesModel> m_apiServicesModel;
|
||||
QSharedPointer<ApiSubscriptionPlansModel> m_apiSubscriptionPlansModel;
|
||||
QSharedPointer<ApiBenefitsModel> m_apiBenefitsModel;
|
||||
QSharedPointer<ApiCountryModel> m_apiCountryModel;
|
||||
QSharedPointer<ApiAccountInfoModel> m_apiAccountInfoModel;
|
||||
QSharedPointer<ApiDevicesModel> m_apiDevicesModel;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ namespace amnezia
|
||||
ApiUpdateRequestError = 1111,
|
||||
ApiSubscriptionExpiredError = 1112,
|
||||
ApiPurchaseError = 1113,
|
||||
ApiSubscriptionNotActiveError = 1114,
|
||||
ApiNoPurchasedSubscriptionsError = 1115,
|
||||
ApiTrialAlreadyUsedError = 1116,
|
||||
|
||||
// QFile errors
|
||||
OpenError = 1200,
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
client/images/controls/globe-2.svg
Normal file
6
client/images/controls/globe-2.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 21V17C15 16.4696 15.2107 15.9609 15.5858 15.5858C15.9609 15.2107 16.4696 15 17 15H21" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 4V6C7.21572 6.61347 7.62494 7.14024 8.16602 7.50096C8.7071 7.86168 9.35075 8.03682 10 8V8C10.5304 8 11.0391 8.21071 11.4142 8.58579C11.7893 8.96086 12 9.46957 12 10C12 10.5304 12.2107 11.0391 12.5858 11.4142C12.9609 11.7893 13.4696 12 14 12C14.5304 12 15.0391 11.7893 15.4142 11.4142C15.7893 11.0391 16 10.5304 16 10C16 9.46957 16.2107 8.96086 16.5858 8.58579C16.9609 8.21071 17.4696 8 18 8H21" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 11H5C5.53043 11 6.03914 11.2107 6.41421 11.5858C6.78929 11.9609 7 12.4696 7 13V14C7 14.5304 7.21071 15.0391 7.58579 15.4142C7.96086 15.7893 8.46957 16 9 16C9.53043 16 10.0391 16.2107 10.4142 16.5858C10.7893 16.9609 11 17.4696 11 18V22" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
client/images/controls/infinity.svg
Normal file
3
client/images/controls/infinity.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.1777 8C23.2737 8 23.2737 16 18.1777 16C13.0827 16 11.0447 8 5.43875 8C0.85375 8 0.85375 16 5.43875 16C11.0447 16 13.0828 8 18.1788 8H18.1777Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 342 B |
4
client/images/controls/smartphone.svg
Normal file
4
client/images/controls/smartphone.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 2H7C5.89543 2 5 2.89543 5 4V20C5 21.1046 5.89543 22 7 22H17C18.1046 22 19 21.1046 19 20V4C19 2.89543 18.1046 2 17 2Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 18H12.01" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 423 B |
178
client/platforms/ios/StoreKit2Helper.swift
Normal file
178
client/platforms/ios/StoreKit2Helper.swift
Normal file
@@ -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<String>, 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))
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,20 @@
|
||||
|
||||
#import "StoreKitController.h"
|
||||
#import <StoreKit/StoreKit.h>
|
||||
#import <AmneziaVPN-Swift.h>
|
||||
|
||||
#include <QtCore/QDebug>
|
||||
#include <QtCore/QString>
|
||||
|
||||
API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
@interface StoreKitController () <SKProductsRequestDelegate, SKPaymentTransactionObserver>
|
||||
@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<NSDictionary *> *_Nullable restoredTransactions,
|
||||
NSError *_Nullable error);
|
||||
@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray<NSDictionary *> *products,
|
||||
NSArray<NSString *> *invalidIdentifiers,
|
||||
NSError *_Nullable error);
|
||||
@property (nonatomic, strong) SKProductsRequest *productsRequest;
|
||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *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<NSDictionary *> *_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<NSDictionary *> *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<NSString *> *)productIdentifiers
|
||||
@@ -102,163 +94,21 @@ API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
NSArray<NSString *> *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<NSDictionary *> *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<SKPaymentTransaction *> *)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<NSDictionary *> *products,
|
||||
NSArray<NSString *> *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<NSDictionary *> *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
|
||||
|
||||
@@ -179,8 +179,9 @@ bool IosController::initialize()
|
||||
[NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray<NETunnelProviderManager *> * _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<NSString *> * _Nonnull invalidIdentifiers,
|
||||
NSError * _Nullable error) {
|
||||
QList<QVariantMap> 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;
|
||||
|
||||
@@ -27,10 +27,12 @@
|
||||
<file>images/controls/folder-open.svg</file>
|
||||
<file>images/controls/folder-search-2.svg</file>
|
||||
<file>images/controls/gauge.svg</file>
|
||||
<file>images/controls/globe-2.svg</file>
|
||||
<file>images/controls/github.svg</file>
|
||||
<file>images/controls/help-circle.svg</file>
|
||||
<file>images/controls/history.svg</file>
|
||||
<file>images/controls/home.svg</file>
|
||||
<file>images/controls/infinity.svg</file>
|
||||
<file>images/controls/info.svg</file>
|
||||
<file>images/controls/mail.svg</file>
|
||||
<file>images/controls/map-pin.svg</file>
|
||||
@@ -55,6 +57,7 @@
|
||||
<file>images/controls/settings-news.svg</file>
|
||||
<file>images/controls/share-2.svg</file>
|
||||
<file>images/controls/split-tunneling.svg</file>
|
||||
<file>images/controls/smartphone.svg</file>
|
||||
<file>images/controls/tag.svg</file>
|
||||
<file>images/controls/telegram.svg</file>
|
||||
<file>images/controls/text-cursor.svg</file>
|
||||
@@ -133,6 +136,10 @@
|
||||
<file>ui/qml/Components/HomeContainersListView.qml</file>
|
||||
<file>ui/qml/Components/HomeSplitTunnelingDrawer.qml</file>
|
||||
<file>ui/qml/Components/InstalledAppsDrawer.qml</file>
|
||||
<file>ui/qml/Components/BenefitRow.qml</file>
|
||||
<file>ui/qml/Components/BenefitsPanel.qml</file>
|
||||
<file>ui/qml/Components/SubscriptionPlanCard.qml</file>
|
||||
<file>ui/qml/Components/TermsAndPrivacyText.qml</file>
|
||||
<file>ui/qml/Components/QuestionDrawer.qml</file>
|
||||
<file>ui/qml/Components/SelectLanguageDrawer.qml</file>
|
||||
<file>ui/qml/Components/SubscriptionExpiredDrawer.qml</file>
|
||||
@@ -181,6 +188,7 @@
|
||||
<file>ui/qml/Controls2/TextTypes/LabelTextType.qml</file>
|
||||
<file>ui/qml/Controls2/TextTypes/ListItemTitleType.qml</file>
|
||||
<file>ui/qml/Controls2/TextTypes/ParagraphTextType.qml</file>
|
||||
<file>ui/qml/Controls2/TextTypes/BadgeTextType.qml</file>
|
||||
<file>ui/qml/Controls2/TextTypes/SmallTextType.qml</file>
|
||||
<file>ui/qml/Controls2/TopCloseButtonType.qml</file>
|
||||
<file>ui/qml/Controls2/VerticalRadioButton.qml</file>
|
||||
@@ -226,7 +234,9 @@
|
||||
<file>ui/qml/Pages2/PageSettingsNewsDetail.qml</file>
|
||||
<file>ui/qml/Pages2/PageProtocolAwgClientSettings.qml</file>
|
||||
<file>ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml</file>
|
||||
<file>ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml</file>
|
||||
<file>ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml</file>
|
||||
<file>ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml</file>
|
||||
<file>ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml</file>
|
||||
<file>ui/qml/Pages2/PageSetupWizardApiServicesList.qml</file>
|
||||
<file>ui/qml/Pages2/PageSetupWizardConfigSource.qml</file>
|
||||
<file>ui/qml/Pages2/PageSetupWizardCredentials.qml</file>
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
#include "ui/controllers/systemController.h"
|
||||
#include "version.h"
|
||||
#include <QClipboard>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QEventLoop>
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QSet>
|
||||
#include <QVariantMap>
|
||||
#include <limits>
|
||||
|
||||
#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<QString> 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<QString, StoreKitPlanQuote> buildStoreKitQuoteMap(const QList<QVariantMap> &fetchedProducts)
|
||||
{
|
||||
QHash<QString, StoreKitPlanQuote> 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<QVariantMap> fetchedProducts;
|
||||
QEventLoop loop;
|
||||
IosController::Instance()->fetchProducts(productIds,
|
||||
[&](const QList<QVariantMap> &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<QString, StoreKitPlanQuote> 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<double>::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<double>::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> &serversModel,
|
||||
const QSharedPointer<ApiServicesModel> &apiServicesModel,
|
||||
const QSharedPointer<ApiSubscriptionPlansModel> &subscriptionPlansModel,
|
||||
const QSharedPointer<ApiBenefitsModel> &benefitsModel,
|
||||
const std::shared_ptr<Settings> &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)
|
||||
@@ -386,47 +574,7 @@ 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<QVariantMap> &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);
|
||||
@@ -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<QVariantMap> 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<QString> processedTransactions;
|
||||
int duplicateServerIndex = -1;
|
||||
QSet<QString> 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<int>(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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#ifndef APICONFIGSCONTROLLER_H
|
||||
#define APICONFIGSCONTROLLER_H
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
|
||||
#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> &serversModel, const QSharedPointer<ApiServicesModel> &apiServicesModel,
|
||||
const std::shared_ptr<Settings> &settings, QObject *parent = nullptr);
|
||||
const QSharedPointer<ApiSubscriptionPlansModel> &subscriptionPlansModel,
|
||||
const QSharedPointer<ApiBenefitsModel> &benefitsModel, const std::shared_ptr<Settings> &settings,
|
||||
QObject *parent = nullptr);
|
||||
|
||||
Q_PROPERTY(QList<QString> 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<QString> m_qrCodes;
|
||||
QString m_vpnKey;
|
||||
@@ -67,6 +74,9 @@ private:
|
||||
QSharedPointer<ServersModel> m_serversModel;
|
||||
QSharedPointer<ApiServicesModel> m_apiServicesModel;
|
||||
std::shared_ptr<Settings> m_settings;
|
||||
|
||||
QSharedPointer<ApiSubscriptionPlansModel> m_subscriptionPlansModel;
|
||||
QSharedPointer<ApiBenefitsModel> m_benefitsModel;
|
||||
};
|
||||
|
||||
#endif // APICONFIGSCONTROLLER_H
|
||||
#endif
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <QApplication>
|
||||
#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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<int, QByteArray> 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
112
client/ui/models/api/apiBenefitsModel.cpp
Normal file
112
client/ui/models/api/apiBenefitsModel.cpp
Normal file
@@ -0,0 +1,112 @@
|
||||
#include "apiBenefitsModel.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <utility>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
|
||||
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<QString, QString> 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<int, QByteArray> 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();
|
||||
}
|
||||
43
client/ui/models/api/apiBenefitsModel.h
Normal file
43
client/ui/models/api/apiBenefitsModel.h
Normal file
@@ -0,0 +1,43 @@
|
||||
#ifndef APIBENEFITSMODEL_H
|
||||
#define APIBENEFITSMODEL_H
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QJsonArray>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
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<int, QByteArray> roleNames() const override;
|
||||
|
||||
void updateModel(const QJsonArray &benefits);
|
||||
void clear();
|
||||
|
||||
private:
|
||||
struct ServiceBenefitItem
|
||||
{
|
||||
QString icon;
|
||||
QString title;
|
||||
QString body;
|
||||
bool accent = false;
|
||||
};
|
||||
|
||||
QVector<ServiceBenefitItem> m_serviceBenefits;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,7 +1,11 @@
|
||||
#include "apiServicesModel.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
#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<int, QByteArray> ApiServicesModel::roleNames() const
|
||||
{
|
||||
QHash<int, QByteArray> roles;
|
||||
@@ -224,12 +233,11 @@ QHash<int, QByteArray> 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;
|
||||
}
|
||||
|
||||
@@ -4,65 +4,23 @@
|
||||
#include <QAbstractListModel>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QVector>
|
||||
|
||||
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<int, QByteArray> 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<int, QByteArray> roleNames() const override;
|
||||
|
||||
private:
|
||||
ApiServicesData getApiServicesData(const QJsonObject &data);
|
||||
|
||||
QString m_countryCode;
|
||||
@@ -93,4 +104,4 @@ private:
|
||||
int m_selectedServiceIndex;
|
||||
};
|
||||
|
||||
#endif // APISERVICESMODEL_H
|
||||
#endif
|
||||
|
||||
131
client/ui/models/api/apiSubscriptionPlansModel.cpp
Normal file
131
client/ui/models/api/apiSubscriptionPlansModel.cpp
Normal file
@@ -0,0 +1,131 @@
|
||||
#include "apiSubscriptionPlansModel.h"
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QModelIndex>
|
||||
#include <utility>
|
||||
|
||||
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<int, QByteArray> 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<int, QByteArray> 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;
|
||||
}
|
||||
53
client/ui/models/api/apiSubscriptionPlansModel.h
Normal file
53
client/ui/models/api/apiSubscriptionPlansModel.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#ifndef APISUBSCRIPTIONPLANSMODEL_H
|
||||
#define APISUBSCRIPTIONPLANSMODEL_H
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QJsonArray>
|
||||
#include <QVector>
|
||||
|
||||
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<int, QByteArray> 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<SubscriptionPlanItem> m_subscriptionPlans;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
65
client/ui/qml/Components/BenefitRow.qml
Normal file
65
client/ui/qml/Components/BenefitRow.qml
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
client/ui/qml/Components/BenefitsPanel.qml
Normal file
40
client/ui/qml/Components/BenefitsPanel.qml
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
94
client/ui/qml/Components/SubscriptionPlanCard.qml
Normal file
94
client/ui/qml/Components/SubscriptionPlanCard.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
35
client/ui/qml/Components/TermsAndPrivacyText.qml
Normal file
35
client/ui/qml/Components/TermsAndPrivacyText.qml
Normal file
@@ -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 <a href=\"%1\" style=\"color: %3;\">Terms of Use</a> and <a href=\"%2\" style=\"color: %3;\">Privacy Policy</a>")
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
client/ui/qml/Controls2/TextTypes/BadgeTextType.qml
Normal file
15
client/ui/qml/Controls2/TextTypes/BadgeTextType.qml
Normal file
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
140
client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml
Normal file
140
client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
198
client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml
Normal file
198
client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <a href=\"%1\" style=\"color: #FBB26A;\">Terms of Use</a> and <a href=\"%2\" style=\"color: #FBB26A;\">Privacy Policy</a>").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<QtObject> 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml
Normal file
138
client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user