chore: minor codestyle fixes

This commit is contained in:
vkamn
2026-04-02 17:32:42 +08:00
parent 6249eea905
commit d3eaead779
9 changed files with 279 additions and 270 deletions

View File

@@ -6,6 +6,7 @@ import StoreKit
public class StoreKit2Helper: NSObject { public class StoreKit2Helper: NSObject {
public static let shared = StoreKit2Helper() public static let shared = StoreKit2Helper()
private static let errorDomain = "StoreKit2Helper"
private struct EntitlementInfo { private struct EntitlementInfo {
let transactionId: UInt64 let transactionId: UInt64
@@ -57,8 +58,8 @@ public class StoreKit2Helper: NSObject {
do { do {
let products = try await Product.products(for: [productIdentifier]) let products = try await Product.products(for: [productIdentifier])
guard let product = products.first else { guard let product = products.first else {
let error = NSError(domain: "StoreKit2Helper", code: 0, userInfo: [NSLocalizedDescriptionKey: "Product not found"]) completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
DispatchQueue.main.async { completion(false, nil, nil, nil, error) } error: makeError(code: 0, description: "Product not found"))
return return
} }
let result = try await product.purchase() let result = try await product.purchase()
@@ -67,25 +68,25 @@ public class StoreKit2Helper: NSObject {
switch verification { switch verification {
case .verified(let transaction): case .verified(let transaction):
await transaction.finish() await transaction.finish()
let txId = String(transaction.id) completePurchase(completion: completion, success: true, transactionId: String(transaction.id),
let origTxId = String(transaction.originalID) productId: transaction.productID, originalTransactionId: String(transaction.originalID), error: nil)
let pId = transaction.productID
DispatchQueue.main.async { completion(true, txId, pId, origTxId, nil) }
case .unverified(_, let error): case .unverified(_, let error):
DispatchQueue.main.async { completion(false, nil, nil, nil, error as NSError) } completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: error as NSError)
} }
case .userCancelled: case .userCancelled:
let error = NSError(domain: "StoreKit2Helper", code: 1, userInfo: [NSLocalizedDescriptionKey: "Purchase cancelled"]) completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
DispatchQueue.main.async { completion(false, nil, nil, nil, error) } error: makeError(code: 1, description: "Purchase cancelled"))
case .pending: case .pending:
let error = NSError(domain: "StoreKit2Helper", code: 2, userInfo: [NSLocalizedDescriptionKey: "Purchase pending"]) completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
DispatchQueue.main.async { completion(false, nil, nil, nil, error) } error: makeError(code: 2, description: "Purchase pending"))
@unknown default: @unknown default:
let error = NSError(domain: "StoreKit2Helper", code: 3, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase result"]) completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
DispatchQueue.main.async { completion(false, nil, nil, nil, error) } error: makeError(code: 3, description: "Unknown purchase result"))
} }
} catch { } catch {
DispatchQueue.main.async { completion(false, nil, nil, nil, error as NSError) } completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: error as NSError)
} }
} }
} }
@@ -95,18 +96,18 @@ public class StoreKit2Helper: NSObject {
} }
private func subscriptionBillingMonths(_ period: Product.SubscriptionPeriod) -> Double { private func subscriptionBillingMonths(_ period: Product.SubscriptionPeriod) -> Double {
let v = Double(period.value) let periodValue = Double(period.value)
switch period.unit { switch period.unit {
case .day: case .day:
return v / 30.0 return periodValue / 30.0
case .week: case .week:
return v * 7.0 / 30.0 return periodValue * 7.0 / 30.0
case .month: case .month:
return v return periodValue
case .year: case .year:
return v * 12.0 return periodValue * 12.0
@unknown default: @unknown default:
return v return periodValue
} }
} }
@@ -114,35 +115,7 @@ public class StoreKit2Helper: NSObject {
Task { Task {
do { do {
let products = try await Product.products(for: identifiers) let products = try await Product.products(for: identifiers)
let productDicts = products.map { product -> NSDictionary in let productDicts = products.map { product in productDictionary(for: product) }
let currencyCode = storefrontCurrencyCode(for: product)
var dict: [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 sub = product.subscription {
let months = subscriptionBillingMonths(sub.subscriptionPeriod)
dict["subscriptionBillingMonths"] = months
if months > 1e-6 {
let perMonth = product.price / Decimal(months)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = product.priceFormatStyle.locale
if !currencyCode.isEmpty {
formatter.currencyCode = currencyCode
}
if let perMonthStr = formatter.string(from: NSDecimalNumber(decimal: perMonth)) {
dict["displayPricePerMonth"] = perMonthStr
}
}
}
return dict as NSDictionary
}
let fetchedIds = Set(products.map { $0.id }) let fetchedIds = Set(products.map { $0.id })
let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) } let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) }
DispatchQueue.main.async { completion(productDicts, Array(invalidIdentifiers), nil) } DispatchQueue.main.async { completion(productDicts, Array(invalidIdentifiers), nil) }
@@ -151,4 +124,55 @@ public class StoreKit2Helper: NSObject {
} }
} }
} }
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))
}
} }

View File

@@ -9,6 +9,14 @@
#include <QtCore/QDebug> #include <QtCore/QDebug>
#include <QtCore/QString> #include <QtCore/QString>
namespace
{
QString toQString(NSString *value)
{
return QString::fromUtf8((value ?: @"").UTF8String);
}
}
API_AVAILABLE(ios(15.0), macos(12.0)) API_AVAILABLE(ios(15.0), macos(12.0))
@implementation StoreKitController @implementation StoreKitController
@@ -45,11 +53,10 @@ API_AVAILABLE(ios(15.0), macos(12.0))
NSString *originalTransactionId, NSString *originalTransactionId,
NSError *error) { NSError *error) {
if (success) { if (success) {
qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << QString::fromUtf8(transactionId.UTF8String) qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << toQString(transactionId)
<< "originalTransactionId =" << QString::fromUtf8(originalTransactionId.UTF8String) << "originalTransactionId =" << toQString(originalTransactionId) << "productId =" << toQString(productId);
<< "productId =" << QString::fromUtf8(productId.UTF8String);
} else if (error) { } else if (error) {
qWarning().noquote() << "[IAP][StoreKit2] Purchase failed:" << QString::fromUtf8(error.localizedDescription.UTF8String); qWarning().noquote() << "[IAP][StoreKit2] Purchase failed:" << toQString(error.localizedDescription);
} }
if (completion) { if (completion) {
completion(success, transactionId, productId, originalTransactionId, error); completion(success, transactionId, productId, originalTransactionId, error);
@@ -67,15 +74,14 @@ API_AVAILABLE(ios(15.0), macos(12.0))
if (success) { if (success) {
qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned" qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned"
<< (int)(entitlements ? entitlements.count : 0) << "active entitlements"; << (int)(entitlements ? entitlements.count : 0) << "active entitlements";
for (NSDictionary *info in entitlements) { for (NSDictionary *entitlement in entitlements) {
qInfo().noquote() << "[IAP][StoreKit2] Active entitlement:" qInfo().noquote() << "[IAP][StoreKit2] Active entitlement:"
<< "transactionId=" << QString::fromUtf8([info[@"transactionId"] UTF8String]) << "transactionId=" << toQString(entitlement[@"transactionId"])
<< "originalTransactionId=" << QString::fromUtf8([info[@"originalTransactionId"] UTF8String]) << "originalTransactionId=" << toQString(entitlement[@"originalTransactionId"])
<< "productId=" << QString::fromUtf8([info[@"productId"] UTF8String]); << "productId=" << toQString(entitlement[@"productId"]);
} }
} else { } else {
qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" << toQString(error.localizedDescription);
<< QString::fromUtf8(error.localizedDescription.UTF8String);
} }
if (completion) { if (completion) {
completion(success, entitlements, error); completion(success, entitlements, error);
@@ -93,10 +99,10 @@ API_AVAILABLE(ios(15.0), macos(12.0))
NSArray<NSString *> *invalidIdentifiers, NSArray<NSString *> *invalidIdentifiers,
NSError *error) { NSError *error) {
if (!error) { if (!error) {
for (NSDictionary *p in products) { for (NSDictionary *productInfo in products) {
qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << QString::fromUtf8([p[@"productId"] UTF8String]) qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << toQString(productInfo[@"productId"])
<< "price=" << QString::fromUtf8([p[@"price"] UTF8String]) << "price=" << toQString(productInfo[@"price"])
<< "currency=" << QString::fromUtf8([p[@"currencyCode"] UTF8String]); << "currency=" << toQString(productInfo[@"currencyCode"]);
} }
} }
if (completion) { if (completion) {

View File

@@ -1147,26 +1147,26 @@ void IosController::fetchProducts(const QStringList &productIds,
NSArray<NSString *> * _Nonnull invalidIdentifiers, NSArray<NSString *> * _Nonnull invalidIdentifiers,
NSError * _Nullable error) { NSError * _Nullable error) {
QList<QVariantMap> outProducts; QList<QVariantMap> outProducts;
for (NSDictionary *p in products) { for (NSDictionary *productInfo in products) {
QVariantMap m; QVariantMap productData;
m["productId"] = QString::fromUtf8([p[@"productId"] UTF8String]); productData["productId"] = QString::fromUtf8([productInfo[@"productId"] UTF8String]);
m["title"] = QString::fromUtf8([p[@"title"] UTF8String]); productData["title"] = QString::fromUtf8([productInfo[@"title"] UTF8String]);
m["description"] = QString::fromUtf8([p[@"description"] UTF8String]); productData["description"] = QString::fromUtf8([productInfo[@"description"] UTF8String]);
m["price"] = QString::fromUtf8([p[@"price"] UTF8String]); productData["price"] = QString::fromUtf8([productInfo[@"price"] UTF8String]);
if (p[@"displayPrice"]) { if (productInfo[@"displayPrice"]) {
m["displayPrice"] = QString::fromUtf8([p[@"displayPrice"] UTF8String]); productData["displayPrice"] = QString::fromUtf8([productInfo[@"displayPrice"] UTF8String]);
} }
m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]); productData["currencyCode"] = QString::fromUtf8([productInfo[@"currencyCode"] UTF8String]);
if (p[@"priceAmount"]) { if (productInfo[@"priceAmount"]) {
m["priceAmount"] = [p[@"priceAmount"] doubleValue]; productData["priceAmount"] = [productInfo[@"priceAmount"] doubleValue];
} }
if (p[@"subscriptionBillingMonths"]) { if (productInfo[@"subscriptionBillingMonths"]) {
m["subscriptionBillingMonths"] = [p[@"subscriptionBillingMonths"] doubleValue]; productData["subscriptionBillingMonths"] = [productInfo[@"subscriptionBillingMonths"] doubleValue];
} }
if (p[@"displayPricePerMonth"]) { if (productInfo[@"displayPricePerMonth"]) {
m["displayPricePerMonth"] = QString::fromUtf8([p[@"displayPricePerMonth"] UTF8String]); productData["displayPricePerMonth"] = QString::fromUtf8([productInfo[@"displayPricePerMonth"] UTF8String]);
} }
outProducts.push_back(m); outProducts.push_back(productData);
} }
QStringList invalid; QStringList invalid;

View File

@@ -139,6 +139,7 @@
<file>ui/qml/Components/BenefitRow.qml</file> <file>ui/qml/Components/BenefitRow.qml</file>
<file>ui/qml/Components/BenefitsPanel.qml</file> <file>ui/qml/Components/BenefitsPanel.qml</file>
<file>ui/qml/Components/SubscriptionPlanCard.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/QuestionDrawer.qml</file>
<file>ui/qml/Components/SelectLanguageDrawer.qml</file> <file>ui/qml/Components/SelectLanguageDrawer.qml</file>
<file>ui/qml/Components/SubscriptionExpiredDrawer.qml</file> <file>ui/qml/Components/SubscriptionExpiredDrawer.qml</file>

View File

@@ -261,6 +261,65 @@ namespace
QString displayPricePerMonth; 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) void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data)
{ {
QJsonArray services = data.value(configKey::services).toArray(); QJsonArray services = data.value(configKey::services).toArray();
@@ -268,28 +327,7 @@ namespace
return; return;
} }
QStringList productIds; const QStringList productIds = collectPremiumStoreProductIds(services);
QSet<QString> seen;
for (int i = 0; i < services.size(); ++i) {
const QJsonObject service = services.at(i).toObject();
if (service.value(configKey::serviceType).toString() != serviceType::amneziaPremium) {
continue;
}
const QJsonObject description = service.value(configKey::serviceDescription).toObject();
const QJsonArray plans = description.value(configKey::subscriptionPlans).toArray();
for (const QJsonValue &planValue : plans) {
if (!planValue.isObject()) {
continue;
}
const QString id = planValue.toObject().value(configKey::storeProductId).toString();
if (id.isEmpty() || seen.contains(id)) {
continue;
}
seen.insert(id);
productIds.append(id);
}
}
if (productIds.isEmpty()) { if (productIds.isEmpty()) {
qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload"; qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload";
return; return;
@@ -311,71 +349,53 @@ namespace
}); });
loop.exec(); loop.exec();
QHash<QString, StoreKitPlanQuote> idToQuote; const QHash<QString, StoreKitPlanQuote> quotesByProductId = buildStoreKitQuoteMap(fetchedProducts);
idToQuote.reserve(fetchedProducts.size());
for (const QVariantMap &product : fetchedProducts) {
const QString id = product.value(QStringLiteral("productId")).toString();
if (id.isEmpty()) {
continue;
}
StoreKitPlanQuote quote;
QString display = product.value(QStringLiteral("displayPrice")).toString();
if (display.isEmpty()) {
const QString price = product.value(QStringLiteral("price")).toString();
const QString currencyCode = product.value(QStringLiteral("currencyCode")).toString();
display = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode);
}
quote.displayPrice = display;
quote.priceAmount = product.value(QStringLiteral("priceAmount")).toDouble();
quote.subscriptionBillingMonths = product.value(QStringLiteral("subscriptionBillingMonths")).toDouble();
quote.displayPricePerMonth = product.value(QStringLiteral("displayPricePerMonth")).toString();
idToQuote.insert(id, quote);
}
for (int i = 0; i < services.size(); ++i) { for (int serviceIndex = 0; serviceIndex < services.size(); ++serviceIndex) {
QJsonObject service = services.at(i).toObject(); QJsonObject serviceObject = services.at(serviceIndex).toObject();
if (service.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) {
continue; continue;
} }
QJsonObject description = service.value(configKey::serviceDescription).toObject();
QJsonArray plans = description.value(configKey::subscriptionPlans).toArray(); QJsonObject descriptionObject = serviceObject.value(configKey::serviceDescription).toObject();
const QJsonArray sourcePlans = descriptionObject.value(configKey::subscriptionPlans).toArray();
QJsonArray mergedPlans; QJsonArray mergedPlans;
double minMonthlyAmount = std::numeric_limits<double>::infinity(); double minMonthlyAmount = std::numeric_limits<double>::infinity();
QString minMonthlyDisplay; QString minMonthlyDisplay;
for (const QJsonValue &planValue : plans) { for (const QJsonValue &planValue : sourcePlans) {
if (!planValue.isObject()) { if (!planValue.isObject()) {
continue; continue;
} }
QJsonObject planObject = planValue.toObject(); QJsonObject planObject = planValue.toObject();
const bool trial = planObject.value(configKey::isTrial).toBool(); const QString storeProductId = planObject.value(configKey::storeProductId).toString();
const QString storeId = planObject.value(configKey::storeProductId).toString(); if (storeProductId.isEmpty()) {
if (storeId.isEmpty()) {
continue; continue;
} }
const auto quoteIt = idToQuote.constFind(storeId); const auto quoteIterator = quotesByProductId.constFind(storeProductId);
if (quoteIt == idToQuote.cend()) { if (quoteIterator == quotesByProductId.cend()) {
continue; continue;
} }
const StoreKitPlanQuote &quote = *quoteIt; const bool isTrialPlan = planObject.value(configKey::isTrial).toBool();
const StoreKitPlanQuote &quote = *quoteIterator;
planObject.insert(configKey::priceLabel, quote.displayPrice); planObject.insert(configKey::priceLabel, quote.displayPrice);
const double months = quote.subscriptionBillingMonths; const double months = quote.subscriptionBillingMonths;
if (!trial && months > 1.0 + 1e-6 && !quote.displayPricePerMonth.isEmpty()) { if (!isTrialPlan && months > kOneMonthThreshold && !quote.displayPricePerMonth.isEmpty()) {
planObject.insert( planObject.insert(
configKey::subtitle, configKey::subtitle,
QCoreApplication::translate("ApiConfigsController", "%1/mo", "IAP: price per month in plan subtitle") QCoreApplication::translate("ApiConfigsController", "%1/mo", "IAP: price per month in plan subtitle")
.arg(quote.displayPricePerMonth)); .arg(quote.displayPricePerMonth));
} }
if (!trial && quote.priceAmount > 0.0) { if (!isTrialPlan && quote.priceAmount > 0.0) {
const double monthsForMin = months > 1e-6 ? months : 1.0; const double monthsForMin = months > kMonthsFallbackThreshold ? months : 1.0;
const double monthly = quote.priceAmount / monthsForMin; const double monthly = quote.priceAmount / monthsForMin;
if (monthly < minMonthlyAmount - 1e-9) { if (monthly < minMonthlyAmount - kMonthlyPriceEpsilon) {
minMonthlyAmount = monthly; minMonthlyAmount = monthly;
minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice; minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice;
} }
@@ -384,15 +404,15 @@ namespace
mergedPlans.append(planObject); mergedPlans.append(planObject);
} }
description.insert(configKey::subscriptionPlans, mergedPlans); descriptionObject.insert(configKey::subscriptionPlans, mergedPlans);
if (minMonthlyAmount < std::numeric_limits<double>::infinity() && !minMonthlyDisplay.isEmpty()) { if (minMonthlyAmount < std::numeric_limits<double>::infinity() && !minMonthlyDisplay.isEmpty()) {
description.insert(configKey::minPriceLabel, descriptionObject.insert(configKey::minPriceLabel,
QCoreApplication::translate("ApiConfigsController", "from %1 per month", QCoreApplication::translate("ApiConfigsController", "from %1 per month",
"IAP: card footer minimum monthly price from StoreKit") "IAP: card footer minimum monthly price from StoreKit")
.arg(minMonthlyDisplay)); .arg(minMonthlyDisplay));
} }
service.insert(configKey::serviceDescription, description); serviceObject.insert(configKey::serviceDescription, descriptionObject);
services.replace(i, service); services.replace(serviceIndex, serviceObject);
} }
data.insert(configKey::services, services); data.insert(configKey::services, services);
} }
@@ -469,6 +489,8 @@ bool ApiConfigsController::exportNativeConfig(const QString &serverCountryCode,
return false; return false;
} }
qDebug() << responseBody;
QJsonObject jsonConfig = QJsonDocument::fromJson(responseBody).object(); QJsonObject jsonConfig = QJsonDocument::fromJson(responseBody).object();
QString nativeConfig = jsonConfig.value(configKey::config).toString(); QString nativeConfig = jsonConfig.value(configKey::config).toString();
nativeConfig.replace("$WIREGUARD_CLIENT_PRIVATE_KEY", protocolData.wireGuardClientPrivKey); nativeConfig.replace("$WIREGUARD_CLIENT_PRIVATE_KEY", protocolData.wireGuardClientPrivKey);
@@ -568,9 +590,9 @@ bool ApiConfigsController::fillAvailableServices()
bool ApiConfigsController::importService() bool ApiConfigsController::importService()
{ {
#if defined(Q_OS_IOS) || defined(MACOS_NE) #if defined(Q_OS_IOS) || defined(MACOS_NE)
bool isIosOrMacOsNe = true; const bool isIosOrMacOsNe = true;
#else #else
bool isIosOrMacOsNe = false; const bool isIosOrMacOsNe = false;
#endif #endif
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) { if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
@@ -578,8 +600,7 @@ bool ApiConfigsController::importService()
return importPremiumFromAppStore(QString()); return importPremiumFromAppStore(QString());
} }
} else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) { } else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) {
importFreeFromGateway(); return importFreeFromGateway();
return true;
} }
return false; return false;
} }
@@ -599,11 +620,11 @@ bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProduct
QString purchaseError; QString purchaseError;
QEventLoop waitPurchase; QEventLoop waitPurchase;
IosController::Instance()->purchaseProduct(productId, IosController::Instance()->purchaseProduct(productId,
[&](bool success, const QString &txId, const QString &purchasedProductId, [&](bool success, const QString &transactionId, const QString &purchasedProductId,
const QString &originalTxId, const QString &errorString) { const QString &originalTransactionIdResponse, const QString &errorString) {
purchaseOk = success; purchaseOk = success;
originalTransactionId = originalTxId; originalTransactionId = originalTransactionIdResponse;
storeTransactionId = txId; storeTransactionId = transactionId;
purchasedStoreProductId = purchasedProductId; purchasedStoreProductId = purchasedProductId;
purchaseError = errorString; purchaseError = errorString;
waitPurchase.quit(); waitPurchase.quit();
@@ -675,20 +696,12 @@ bool ApiConfigsController::restoreServiceFromAppStore()
return false; return false;
} }
// Ensure we have a valid premium selection for gateway requests const int premiumServiceIndex = m_apiServicesModel->serviceIndexForType(premiumServiceType);
bool premiumSelected = false; if (premiumServiceIndex < 0) {
for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) {
m_apiServicesModel->setServiceIndex(i);
if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) {
premiumSelected = true;
break;
}
}
if (!premiumSelected) {
emit errorOccurred(ErrorCode::ApiServicesMissingError); emit errorOccurred(ErrorCode::ApiServicesMissingError);
return false; return false;
} }
m_apiServicesModel->setServiceIndex(premiumServiceIndex);
bool restoreSuccess = false; bool restoreSuccess = false;
QList<QVariantMap> restoredTransactions; QList<QVariantMap> restoredTransactions;
@@ -1196,47 +1209,54 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo
#if defined(Q_OS_IOS) || defined(MACOS_NE) #if defined(Q_OS_IOS) || defined(MACOS_NE)
duplicateServerIndex = -1; duplicateServerIndex = -1;
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object(); QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
QString key = responseObject.value(QStringLiteral("key")).toString(); const QString rawVpnKey = responseObject.value(QStringLiteral("key")).toString();
if (key.isEmpty()) { if (rawVpnKey.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response does not contain a key field"; qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
return ErrorCode::ApiPurchaseError; return ErrorCode::ApiPurchaseError;
} }
QString normalizedKey = key; QString normalizedVpnKey = rawVpnKey;
normalizedKey.replace(QStringLiteral("vpn://"), QString()); normalizedVpnKey.replace(QStringLiteral("vpn://"), QString());
QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey);
QByteArray configUncompressed = qUncompress(configString); if (duplicateServerIndex >= 0) {
const bool payloadWasCompressed = !configUncompressed.isEmpty(); qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
if (payloadWasCompressed) { return ErrorCode::ApiConfigAlreadyAdded;
configString = configUncompressed;
} }
if (configString.isEmpty()) { QByteArray configPayload =
QByteArray::fromBase64(normalizedVpnKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QByteArray configUncompressed = qUncompress(configPayload);
const bool payloadWasCompressed = !configUncompressed.isEmpty();
if (payloadWasCompressed) {
configPayload = configUncompressed;
}
if (configPayload.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response config payload is empty"; qWarning().noquote() << "[IAP] Subscription response config payload is empty";
return ErrorCode::ApiPurchaseError; return ErrorCode::ApiPurchaseError;
} }
QJsonObject configObject = QJsonDocument::fromJson(configString).object(); QJsonObject configObject = QJsonDocument::fromJson(configPayload).object();
auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject(); auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject();
apiConfig.insert(apiDefs::key::isTestPurchase, isTestPurchase); apiConfig.insert(apiDefs::key::isTestPurchase, isTestPurchase);
apiConfig.insert(apiDefs::key::isInAppPurchase, true); apiConfig.insert(apiDefs::key::isInAppPurchase, true);
configObject.insert(apiDefs::key::apiConfig, apiConfig); configObject.insert(apiDefs::key::apiConfig, apiConfig);
configString = QJsonDocument(configObject).toJson(); configPayload = QJsonDocument(configObject).toJson();
if (payloadWasCompressed) { if (payloadWasCompressed) {
configString = qCompress(configString, 8); configPayload = qCompress(configPayload, 8);
} }
normalizedKey = QString(configString.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); normalizedVpnKey = QString(configPayload.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedKey); duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey);
if (duplicateServerIndex >= 0) { if (duplicateServerIndex >= 0) {
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
return ErrorCode::ApiConfigAlreadyAdded; return ErrorCode::ApiConfigAlreadyAdded;
} }
apiConfig.insert(apiDefs::key::vpnKey, normalizedKey); apiConfig.insert(apiDefs::key::vpnKey, normalizedVpnKey);
configObject.insert(apiDefs::key::apiConfig, apiConfig); configObject.insert(apiDefs::key::apiConfig, apiConfig);
quint16 crc = qChecksum(QJsonDocument(configObject).toJson()); quint16 crc = qChecksum(QJsonDocument(configObject).toJson());

View 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
}
}

View File

@@ -85,7 +85,7 @@ PageType {
benefitsModel: ApiBenefitsModel benefitsModel: ApiBenefitsModel
} }
ParagraphTextType { TermsAndPrivacyText {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 16 Layout.leftMargin: 16
Layout.rightMargin: 16 Layout.rightMargin: 16
@@ -93,30 +93,11 @@ PageType {
visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild)
horizontalAlignment: Text.AlignHCenter termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl"))
textFormat: Text.RichText privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl"))
color: AmneziaStyle.color.mutedGray
font.pixelSize: 12
text: {
return 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(String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")))
.arg(String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")))
.arg(AmneziaStyle.color.goldenApricotString)
}
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
} }
ParagraphTextType { TermsAndPrivacyText {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 16 Layout.leftMargin: 16
Layout.rightMargin: 16 Layout.rightMargin: 16
@@ -124,27 +105,8 @@ PageType {
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) visible: (Qt.platform.os === "ios" || IsMacOsNeBuild)
horizontalAlignment: Text.AlignHCenter termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
textFormat: Text.RichText privacyUrl: LanguageModel.getCurrentSiteUrl("policy")
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: %3;\">Terms of Use</a> and <a href=\"%2\" style=\"color: %3;\">Privacy Policy</a>")
.arg(termsUrl).arg(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
}
} }
} }
} }

View File

@@ -132,33 +132,13 @@ PageType {
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.") 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.")
} }
ParagraphTextType { TermsAndPrivacyText {
Layout.fillWidth: true termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
horizontalAlignment: Text.AlignHCenter privacyUrl: LanguageModel.getCurrentSiteUrl("policy")
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: %3;\">Terms of Use</a> and <a href=\"%2\" style=\"color: %3;\">Privacy Policy</a>")
.arg(termsUrl).arg(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
}
} }
} }
ParagraphTextType { TermsAndPrivacyText {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 16 Layout.leftMargin: 16
Layout.rightMargin: 16 Layout.rightMargin: 16
@@ -166,27 +146,8 @@ PageType {
visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild)
horizontalAlignment: Text.AlignHCenter termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl"))
textFormat: Text.RichText privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl"))
color: AmneziaStyle.color.mutedGray
font.pixelSize: 12
text: {
return 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(String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")))
.arg(String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")))
.arg(AmneziaStyle.color.goldenApricotString)
}
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
} }
} }
} }

View File

@@ -78,7 +78,7 @@ PageType {
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
color: AmneziaStyle.color.mutedGray color: AmneziaStyle.color.mutedGray
font.pixelSize: 12 font.pixelSize: 12
text: qsTr("Additional details for this step can be described here.") text: qsTr("We will create an account for your trial subscription and send important subscription updates to this email.")
} }
} }
} }