diff --git a/client/platforms/ios/StoreKit2Helper.swift b/client/platforms/ios/StoreKit2Helper.swift index dc150d22b..c0472b389 100644 --- a/client/platforms/ios/StoreKit2Helper.swift +++ b/client/platforms/ios/StoreKit2Helper.swift @@ -6,6 +6,7 @@ import StoreKit public class StoreKit2Helper: NSObject { public static let shared = StoreKit2Helper() + private static let errorDomain = "StoreKit2Helper" private struct EntitlementInfo { let transactionId: UInt64 @@ -57,8 +58,8 @@ public class StoreKit2Helper: NSObject { do { let products = try await Product.products(for: [productIdentifier]) guard let product = products.first else { - let error = NSError(domain: "StoreKit2Helper", code: 0, userInfo: [NSLocalizedDescriptionKey: "Product not found"]) - DispatchQueue.main.async { completion(false, nil, nil, nil, error) } + 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() @@ -67,25 +68,25 @@ public class StoreKit2Helper: NSObject { switch verification { case .verified(let transaction): await transaction.finish() - let txId = String(transaction.id) - let origTxId = String(transaction.originalID) - let pId = transaction.productID - DispatchQueue.main.async { completion(true, txId, pId, origTxId, nil) } + completePurchase(completion: completion, success: true, transactionId: String(transaction.id), + productId: transaction.productID, originalTransactionId: String(transaction.originalID), error: nil) 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: - let error = NSError(domain: "StoreKit2Helper", code: 1, userInfo: [NSLocalizedDescriptionKey: "Purchase cancelled"]) - DispatchQueue.main.async { completion(false, nil, nil, nil, error) } + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 1, description: "Purchase cancelled")) case .pending: - let error = NSError(domain: "StoreKit2Helper", code: 2, userInfo: [NSLocalizedDescriptionKey: "Purchase pending"]) - DispatchQueue.main.async { completion(false, nil, nil, nil, error) } + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 2, description: "Purchase pending")) @unknown default: - let error = NSError(domain: "StoreKit2Helper", code: 3, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase result"]) - DispatchQueue.main.async { completion(false, nil, nil, nil, error) } + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 3, description: "Unknown purchase result")) } } 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 { - let v = Double(period.value) + let periodValue = Double(period.value) switch period.unit { case .day: - return v / 30.0 + return periodValue / 30.0 case .week: - return v * 7.0 / 30.0 + return periodValue * 7.0 / 30.0 case .month: - return v + return periodValue case .year: - return v * 12.0 + return periodValue * 12.0 @unknown default: - return v + return periodValue } } @@ -114,35 +115,7 @@ public class StoreKit2Helper: NSObject { Task { do { let products = try await Product.products(for: identifiers) - let productDicts = products.map { product -> NSDictionary in - 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 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) } @@ -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)) + } } diff --git a/client/platforms/ios/StoreKitController.mm b/client/platforms/ios/StoreKitController.mm index a294b71f0..14a1e39a4 100644 --- a/client/platforms/ios/StoreKitController.mm +++ b/client/platforms/ios/StoreKitController.mm @@ -9,6 +9,14 @@ #include #include +namespace +{ +QString toQString(NSString *value) +{ + return QString::fromUtf8((value ?: @"").UTF8String); +} +} + API_AVAILABLE(ios(15.0), macos(12.0)) @implementation StoreKitController @@ -45,11 +53,10 @@ API_AVAILABLE(ios(15.0), macos(12.0)) NSString *originalTransactionId, NSError *error) { if (success) { - qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << QString::fromUtf8(transactionId.UTF8String) - << "originalTransactionId =" << QString::fromUtf8(originalTransactionId.UTF8String) - << "productId =" << QString::fromUtf8(productId.UTF8String); + qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << toQString(transactionId) + << "originalTransactionId =" << toQString(originalTransactionId) << "productId =" << toQString(productId); } 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) { completion(success, transactionId, productId, originalTransactionId, error); @@ -67,15 +74,14 @@ API_AVAILABLE(ios(15.0), macos(12.0)) if (success) { qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned" << (int)(entitlements ? entitlements.count : 0) << "active entitlements"; - for (NSDictionary *info in entitlements) { + for (NSDictionary *entitlement in entitlements) { qInfo().noquote() << "[IAP][StoreKit2] Active entitlement:" - << "transactionId=" << QString::fromUtf8([info[@"transactionId"] UTF8String]) - << "originalTransactionId=" << QString::fromUtf8([info[@"originalTransactionId"] UTF8String]) - << "productId=" << QString::fromUtf8([info[@"productId"] UTF8String]); + << "transactionId=" << toQString(entitlement[@"transactionId"]) + << "originalTransactionId=" << toQString(entitlement[@"originalTransactionId"]) + << "productId=" << toQString(entitlement[@"productId"]); } } else { - qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" - << QString::fromUtf8(error.localizedDescription.UTF8String); + qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" << toQString(error.localizedDescription); } if (completion) { completion(success, entitlements, error); @@ -93,10 +99,10 @@ API_AVAILABLE(ios(15.0), macos(12.0)) NSArray *invalidIdentifiers, NSError *error) { if (!error) { - for (NSDictionary *p in products) { - qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << QString::fromUtf8([p[@"productId"] UTF8String]) - << "price=" << QString::fromUtf8([p[@"price"] UTF8String]) - << "currency=" << QString::fromUtf8([p[@"currencyCode"] UTF8String]); + for (NSDictionary *productInfo in products) { + qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << toQString(productInfo[@"productId"]) + << "price=" << toQString(productInfo[@"price"]) + << "currency=" << toQString(productInfo[@"currencyCode"]); } } if (completion) { diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index f4769933a..b2a5dcd30 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -1147,26 +1147,26 @@ void IosController::fetchProducts(const QStringList &productIds, NSArray * _Nonnull invalidIdentifiers, NSError * _Nullable error) { QList outProducts; - for (NSDictionary *p in products) { - QVariantMap m; - m["productId"] = QString::fromUtf8([p[@"productId"] UTF8String]); - m["title"] = QString::fromUtf8([p[@"title"] UTF8String]); - m["description"] = QString::fromUtf8([p[@"description"] UTF8String]); - m["price"] = QString::fromUtf8([p[@"price"] UTF8String]); - if (p[@"displayPrice"]) { - m["displayPrice"] = QString::fromUtf8([p[@"displayPrice"] UTF8String]); + 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]); } - m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]); - if (p[@"priceAmount"]) { - m["priceAmount"] = [p[@"priceAmount"] doubleValue]; + productData["currencyCode"] = QString::fromUtf8([productInfo[@"currencyCode"] UTF8String]); + if (productInfo[@"priceAmount"]) { + productData["priceAmount"] = [productInfo[@"priceAmount"] doubleValue]; } - if (p[@"subscriptionBillingMonths"]) { - m["subscriptionBillingMonths"] = [p[@"subscriptionBillingMonths"] doubleValue]; + if (productInfo[@"subscriptionBillingMonths"]) { + productData["subscriptionBillingMonths"] = [productInfo[@"subscriptionBillingMonths"] doubleValue]; } - if (p[@"displayPricePerMonth"]) { - m["displayPricePerMonth"] = QString::fromUtf8([p[@"displayPricePerMonth"] UTF8String]); + if (productInfo[@"displayPricePerMonth"]) { + productData["displayPricePerMonth"] = QString::fromUtf8([productInfo[@"displayPricePerMonth"] UTF8String]); } - outProducts.push_back(m); + outProducts.push_back(productData); } QStringList invalid; diff --git a/client/resources.qrc b/client/resources.qrc index e377a0d3f..51b378af5 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -139,6 +139,7 @@ ui/qml/Components/BenefitRow.qml ui/qml/Components/BenefitsPanel.qml ui/qml/Components/SubscriptionPlanCard.qml + ui/qml/Components/TermsAndPrivacyText.qml ui/qml/Components/QuestionDrawer.qml ui/qml/Components/SelectLanguageDrawer.qml ui/qml/Components/SubscriptionExpiredDrawer.qml diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index 5dfe2ce90..794444657 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -261,6 +261,65 @@ namespace QString displayPricePerMonth; }; + constexpr double kOneMonthThreshold = 1.0 + 1e-6; + constexpr double kMonthsFallbackThreshold = 1e-6; + constexpr double kMonthlyPriceEpsilon = 1e-9; + + QStringList collectPremiumStoreProductIds(const QJsonArray &services) + { + QStringList productIds; + QSet seenProductIds; + for (const QJsonValue &serviceValue : services) { + const QJsonObject serviceObject = serviceValue.toObject(); + if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { + continue; + } + const QJsonArray subscriptionPlans = + serviceObject.value(configKey::serviceDescription).toObject().value(configKey::subscriptionPlans).toArray(); + for (const QJsonValue &planValue : subscriptionPlans) { + if (!planValue.isObject()) { + continue; + } + const QString storeProductId = planValue.toObject().value(configKey::storeProductId).toString(); + if (storeProductId.isEmpty() || seenProductIds.contains(storeProductId)) { + continue; + } + seenProductIds.insert(storeProductId); + productIds.append(storeProductId); + } + } + return productIds; + } + + QHash buildStoreKitQuoteMap(const QList &fetchedProducts) + { + QHash quotesByProductId; + quotesByProductId.reserve(fetchedProducts.size()); + + for (const QVariantMap &productInfo : fetchedProducts) { + const QString productId = productInfo.value(QStringLiteral("productId")).toString(); + if (productId.isEmpty()) { + continue; + } + + QString displayPrice = productInfo.value(QStringLiteral("displayPrice")).toString(); + if (displayPrice.isEmpty()) { + const QString price = productInfo.value(QStringLiteral("price")).toString(); + const QString currencyCode = productInfo.value(QStringLiteral("currencyCode")).toString(); + displayPrice = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode); + } + + StoreKitPlanQuote quote; + quote.displayPrice = displayPrice; + quote.priceAmount = productInfo.value(QStringLiteral("priceAmount")).toDouble(); + quote.subscriptionBillingMonths = productInfo.value(QStringLiteral("subscriptionBillingMonths")).toDouble(); + quote.displayPricePerMonth = productInfo.value(QStringLiteral("displayPricePerMonth")).toString(); + quotesByProductId.insert(productId, quote); + } + + return quotesByProductId; + } + void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data) { QJsonArray services = data.value(configKey::services).toArray(); @@ -268,28 +327,7 @@ namespace return; } - QStringList productIds; - QSet 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); - } - } - + 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; @@ -311,71 +349,53 @@ namespace }); loop.exec(); - QHash idToQuote; - 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); - } + const QHash quotesByProductId = buildStoreKitQuoteMap(fetchedProducts); - for (int i = 0; i < services.size(); ++i) { - QJsonObject service = services.at(i).toObject(); - if (service.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { + for (int serviceIndex = 0; serviceIndex < services.size(); ++serviceIndex) { + QJsonObject serviceObject = services.at(serviceIndex).toObject(); + if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { 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; double minMonthlyAmount = std::numeric_limits::infinity(); QString minMonthlyDisplay; - for (const QJsonValue &planValue : plans) { + for (const QJsonValue &planValue : sourcePlans) { if (!planValue.isObject()) { continue; } + QJsonObject planObject = planValue.toObject(); - const bool trial = planObject.value(configKey::isTrial).toBool(); - const QString storeId = planObject.value(configKey::storeProductId).toString(); - - if (storeId.isEmpty()) { + const QString storeProductId = planObject.value(configKey::storeProductId).toString(); + if (storeProductId.isEmpty()) { continue; } - const auto quoteIt = idToQuote.constFind(storeId); - if (quoteIt == idToQuote.cend()) { + const auto quoteIterator = quotesByProductId.constFind(storeProductId); + if (quoteIterator == quotesByProductId.cend()) { continue; } - const StoreKitPlanQuote "e = *quoteIt; + const bool isTrialPlan = planObject.value(configKey::isTrial).toBool(); + const StoreKitPlanQuote "e = *quoteIterator; planObject.insert(configKey::priceLabel, quote.displayPrice); const double months = quote.subscriptionBillingMonths; - if (!trial && months > 1.0 + 1e-6 && !quote.displayPricePerMonth.isEmpty()) { + 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 (!trial && quote.priceAmount > 0.0) { - const double monthsForMin = months > 1e-6 ? months : 1.0; + if (!isTrialPlan && quote.priceAmount > 0.0) { + const double monthsForMin = months > kMonthsFallbackThreshold ? months : 1.0; const double monthly = quote.priceAmount / monthsForMin; - if (monthly < minMonthlyAmount - 1e-9) { + if (monthly < minMonthlyAmount - kMonthlyPriceEpsilon) { minMonthlyAmount = monthly; minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice; } @@ -384,15 +404,15 @@ namespace mergedPlans.append(planObject); } - description.insert(configKey::subscriptionPlans, mergedPlans); + descriptionObject.insert(configKey::subscriptionPlans, mergedPlans); if (minMonthlyAmount < std::numeric_limits::infinity() && !minMonthlyDisplay.isEmpty()) { - description.insert(configKey::minPriceLabel, - QCoreApplication::translate("ApiConfigsController", "from %1 per month", - "IAP: card footer minimum monthly price from StoreKit") - .arg(minMonthlyDisplay)); + descriptionObject.insert(configKey::minPriceLabel, + QCoreApplication::translate("ApiConfigsController", "from %1 per month", + "IAP: card footer minimum monthly price from StoreKit") + .arg(minMonthlyDisplay)); } - service.insert(configKey::serviceDescription, description); - services.replace(i, service); + serviceObject.insert(configKey::serviceDescription, descriptionObject); + services.replace(serviceIndex, serviceObject); } data.insert(configKey::services, services); } @@ -469,6 +489,8 @@ bool ApiConfigsController::exportNativeConfig(const QString &serverCountryCode, return false; } + qDebug() << responseBody; + QJsonObject jsonConfig = QJsonDocument::fromJson(responseBody).object(); QString nativeConfig = jsonConfig.value(configKey::config).toString(); nativeConfig.replace("$WIREGUARD_CLIENT_PRIVATE_KEY", protocolData.wireGuardClientPrivKey); @@ -568,9 +590,9 @@ 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) { @@ -578,8 +600,7 @@ bool ApiConfigsController::importService() return importPremiumFromAppStore(QString()); } } else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) { - importFreeFromGateway(); - return true; + return importFreeFromGateway(); } return false; } @@ -599,11 +620,11 @@ bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProduct QString purchaseError; QEventLoop waitPurchase; IosController::Instance()->purchaseProduct(productId, - [&](bool success, const QString &txId, const QString &purchasedProductId, - const QString &originalTxId, const QString &errorString) { + [&](bool success, const QString &transactionId, const QString &purchasedProductId, + const QString &originalTransactionIdResponse, const QString &errorString) { purchaseOk = success; - originalTransactionId = originalTxId; - storeTransactionId = txId; + originalTransactionId = originalTransactionIdResponse; + storeTransactionId = transactionId; purchasedStoreProductId = purchasedProductId; purchaseError = errorString; waitPurchase.quit(); @@ -675,20 +696,12 @@ bool ApiConfigsController::restoreServiceFromAppStore() return false; } - // Ensure we have a valid premium selection for gateway requests - bool premiumSelected = false; - for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) { - m_apiServicesModel->setServiceIndex(i); - if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) { - premiumSelected = true; - break; - } - } - - if (!premiumSelected) { + const int premiumServiceIndex = m_apiServicesModel->serviceIndexForType(premiumServiceType); + if (premiumServiceIndex < 0) { emit errorOccurred(ErrorCode::ApiServicesMissingError); return false; } + m_apiServicesModel->setServiceIndex(premiumServiceIndex); bool restoreSuccess = false; QList restoredTransactions; @@ -1196,47 +1209,54 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo #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; } - QString normalizedKey = key; - normalizedKey.replace(QStringLiteral("vpn://"), QString()); + QString normalizedVpnKey = rawVpnKey; + normalizedVpnKey.replace(QStringLiteral("vpn://"), QString()); - QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); - QByteArray configUncompressed = qUncompress(configString); - const bool payloadWasCompressed = !configUncompressed.isEmpty(); - if (payloadWasCompressed) { - configString = configUncompressed; + duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey); + if (duplicateServerIndex >= 0) { + qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; + return ErrorCode::ApiConfigAlreadyAdded; } - 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"; 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); - configString = QJsonDocument(configObject).toJson(); + configPayload = QJsonDocument(configObject).toJson(); 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) { qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; return ErrorCode::ApiConfigAlreadyAdded; } - apiConfig.insert(apiDefs::key::vpnKey, normalizedKey); + apiConfig.insert(apiDefs::key::vpnKey, normalizedVpnKey); configObject.insert(apiDefs::key::apiConfig, apiConfig); quint16 crc = qChecksum(QJsonDocument(configObject).toJson()); diff --git a/client/ui/qml/Components/TermsAndPrivacyText.qml b/client/ui/qml/Components/TermsAndPrivacyText.qml new file mode 100644 index 000000000..5eb4a142a --- /dev/null +++ b/client/ui/qml/Components/TermsAndPrivacyText.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2/TextTypes" + +ParagraphTextType { + id: root + + property string termsUrl: "" + property string privacyUrl: "" + + Layout.fillWidth: true + + horizontalAlignment: Text.AlignHCenter + textFormat: Text.RichText + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + + text: qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") + .arg(root.termsUrl) + .arg(root.privacyUrl) + .arg(AmneziaStyle.color.goldenApricotString) + + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml index e4babaad5..507e0d621 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml @@ -85,7 +85,7 @@ PageType { benefitsModel: ApiBenefitsModel } - ParagraphTextType { + TermsAndPrivacyText { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 @@ -93,30 +93,11 @@ PageType { visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) - horizontalAlignment: Text.AlignHCenter - textFormat: Text.RichText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: { - return qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") - .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 - } + termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")) + privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")) } - ParagraphTextType { + TermsAndPrivacyText { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 @@ -124,27 +105,8 @@ PageType { visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) - horizontalAlignment: Text.AlignHCenter - textFormat: Text.RichText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: { - var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" - var privacyUrl = LanguageModel.getCurrentSiteUrl("policy") - return qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") - .arg(termsUrl).arg(privacyUrl).arg(AmneziaStyle.color.goldenApricotString) - } - - onLinkActivated: function(link) { - Qt.openUrlExternally(link) - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } + termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + privacyUrl: LanguageModel.getCurrentSiteUrl("policy") } } } diff --git a/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml index ac9a387f5..b2fcce852 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml @@ -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.") } - ParagraphTextType { - Layout.fillWidth: true - horizontalAlignment: Text.AlignHCenter - textFormat: Text.RichText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: { - var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" - var privacyUrl = LanguageModel.getCurrentSiteUrl("policy") - return qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") - .arg(termsUrl).arg(privacyUrl).arg(AmneziaStyle.color.goldenApricotString) - } - - onLinkActivated: function(link) { - Qt.openUrlExternally(link) - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } + TermsAndPrivacyText { + termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + privacyUrl: LanguageModel.getCurrentSiteUrl("policy") } } - ParagraphTextType { + TermsAndPrivacyText { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 @@ -166,27 +146,8 @@ PageType { visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) - horizontalAlignment: Text.AlignHCenter - textFormat: Text.RichText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: { - return qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") - .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 - } + termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")) + privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")) } } } diff --git a/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml index 54c92c841..ff8a5040e 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml @@ -78,7 +78,7 @@ PageType { wrapMode: Text.WordWrap color: AmneziaStyle.color.mutedGray 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.") } } }