feat: add iap support for new premium info page

This commit is contained in:
vkamn
2026-03-31 16:12:34 +08:00
parent 285b9344c4
commit a4b97e8764
7 changed files with 161 additions and 57 deletions

View File

@@ -105,6 +105,7 @@ public class StoreKit2Helper: NSObject {
"title": product.displayName,
"description": product.description,
"price": "\(product.price)",
"displayPrice": product.displayPrice,
"currencyCode": currencyCode
]
}

View File

@@ -1153,6 +1153,9 @@ void IosController::fetchProducts(const QStringList &productIds,
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]);
}
m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]);
outProducts.push_back(m);
}

View File

@@ -11,6 +11,8 @@
#include <QClipboard>
#include <QDebug>
#include <QEventLoop>
#include <QHash>
#include <QJsonArray>
#include <QSet>
#include <QVariantMap>
@@ -40,6 +42,12 @@ 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 primaryRight[] = "primary_right";
constexpr char apiPayload[] = "api_payload";
constexpr char keyPayload[] = "key_payload";
@@ -239,6 +247,108 @@ namespace
return ErrorCode::NoError;
}
#if defined(Q_OS_IOS) || defined(MACOS_NE)
void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data)
{
QJsonArray services = data.value(configKey::services).toArray();
if (services.isEmpty()) {
return;
}
QStringList productIds;
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()) {
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();
QHash<QString, QString> idToDisplayPrice;
idToDisplayPrice.reserve(fetchedProducts.size());
for (const QVariantMap &product : fetchedProducts) {
const QString id = product.value(QStringLiteral("productId")).toString();
if (id.isEmpty()) {
continue;
}
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);
}
idToDisplayPrice.insert(id, display);
}
for (int i = 0; i < services.size(); ++i) {
QJsonObject service = services.at(i).toObject();
if (service.value(configKey::serviceType).toString() != serviceType::amneziaPremium) {
continue;
}
QJsonObject description = service.value(configKey::serviceDescription).toObject();
QJsonArray plans = description.value(configKey::subscriptionPlans).toArray();
QJsonArray mergedPlans;
for (const QJsonValue &planValue : plans) {
if (!planValue.isObject()) {
continue;
}
QJsonObject planObject = planValue.toObject();
const QString storeId = planObject.value(configKey::storeProductId).toString();
if (storeId.isEmpty()) {
continue;
}
const auto priceIt = idToDisplayPrice.constFind(storeId);
if (priceIt == idToDisplayPrice.cend()) {
continue;
}
planObject.insert(configKey::primaryRight, *priceIt);
mergedPlans.append(planObject);
}
plans = mergedPlans;
description.insert(configKey::subscriptionPlans, plans);
service.insert(configKey::serviceDescription, description);
services.replace(i, service);
}
data.insert(configKey::services, services);
}
#endif
}
ApiConfigsController::ApiConfigsController(const QSharedPointer<ServersModel> &serversModel,
@@ -395,51 +505,11 @@ bool ApiConfigsController::fillAvailableServices()
}
QJsonObject data = QJsonDocument::fromJson(responseBody).object();
#if defined(Q_OS_IOS) || defined(MACOS_NE)
QEventLoop waitProducts;
bool productsFetched = false;
QString productPrice;
QString productCurrency;
IosController::Instance()->fetchProducts(QStringList() << QStringLiteral("amnezia_premium_6_month"),
[&](const QList<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);
if (m_apiServicesModel->rowCount() > 0) {
m_apiServicesModel->setServiceIndex(0);
@@ -457,7 +527,7 @@ bool ApiConfigsController::importService()
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
if (isIosOrMacOsNe) {
return importServiceFromAppStore();
return importPremiumFromAppStore(QString());
}
} else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) {
importFreeFromGateway();
@@ -466,22 +536,27 @@ bool ApiConfigsController::importService()
return false;
}
bool ApiConfigsController::importServiceFromAppStore()
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"),
IosController::Instance()->purchaseProduct(productId,
[&](bool success, const QString &txId, const QString &purchasedProductId,
const QString &originalTxId, const QString &errorString) {
purchaseOk = success;
originalTransactionId = originalTxId;
storeTransactionId = txId;
storeProductId = purchasedProductId;
purchasedStoreProductId = purchasedProductId;
purchaseError = errorString;
waitPurchase.quit();
});
@@ -493,7 +568,7 @@ bool ApiConfigsController::importServiceFromAppStore()
return false;
}
qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId
<< "originalTransactionId =" << originalTransactionId << "productId =" << storeProductId;
<< "originalTransactionId =" << originalTransactionId << "productId =" << purchasedStoreProductId;
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
@@ -529,8 +604,11 @@ bool ApiConfigsController::importServiceFromAppStore()
}
emit installServerFromApiFinished(
tr("%1 was added to the app.").arg(m_apiServicesModel->getSelectedServiceName()));
#endif
return true;
#else
Q_UNUSED(storeProductId);
return false;
#endif
}
bool ApiConfigsController::restoreServiceFromAppStore()

View File

@@ -1,6 +1,7 @@
#ifndef APICONFIGSCONTROLLER_H
#define APICONFIGSCONTROLLER_H
#include <QList>
#include <QObject>
#include "configurators/openvpn_configurator.h"
@@ -31,7 +32,7 @@ public slots:
bool fillAvailableServices();
bool importService();
bool importServiceFromAppStore();
bool importPremiumFromAppStore(const QString &storeProductId);
bool restoreServiceFromAppStore();
bool importFreeFromGateway();
bool importTrialFromGateway(const QString &email);

View File

@@ -2,7 +2,6 @@
#include <QJsonObject>
#include <QJsonValue>
#include <QVariantMap>
#include <utility>
namespace
@@ -16,12 +15,14 @@ namespace configKey
constexpr char checkoutUrl[] = "checkout_url";
constexpr char isTrial[] = "is_trial";
constexpr char serviceProtocol[] = "service_protocol";
constexpr char storeProductId[] = "store_product_id";
constexpr char primaryLeftCamel[] = "primaryLeft";
constexpr char primaryRightCamel[] = "primaryRight";
constexpr char checkoutUrlCamel[] = "checkoutUrl";
constexpr char isTrialCamel[] = "isTrial";
constexpr char serviceProtocolCamel[] = "serviceProtocol";
constexpr char storeProductIdCamel[] = "storeProductId";
}
}
@@ -59,6 +60,8 @@ QVariant ApiSubscriptionPlansModel::data(const QModelIndex &index, int role) con
return plan.isTrial;
case ServiceProtocolRole:
return plan.serviceProtocol;
case StoreProductIdRole:
return plan.storeProductId;
default:
return {};
}
@@ -74,6 +77,7 @@ QHash<int, QByteArray> ApiSubscriptionPlansModel::roleNames() const
{ CheckoutUrlRole, "checkoutUrl" },
{ IsTrialRole, "isTrial" },
{ ServiceProtocolRole, "serviceProtocol" },
{ StoreProductIdRole, "storeProductId" },
};
}
@@ -95,6 +99,7 @@ void ApiSubscriptionPlansModel::updateModel(const QJsonArray &arr)
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();
@@ -121,6 +126,7 @@ QVariantMap ApiSubscriptionPlansModel::planAt(int row) const
planMap.insert(QLatin1String(configKey::checkoutUrlCamel), plan.checkoutUrl);
planMap.insert(QLatin1String(configKey::isTrialCamel), plan.isTrial);
planMap.insert(QLatin1String(configKey::serviceProtocolCamel), plan.serviceProtocol);
planMap.insert(QLatin1String(configKey::storeProductIdCamel), plan.storeProductId);
return planMap;
}

View File

@@ -17,7 +17,8 @@ public:
RecommendedRole,
CheckoutUrlRole,
IsTrialRole,
ServiceProtocolRole
ServiceProtocolRole,
StoreProductIdRole
};
Q_ENUM(Roles)
@@ -43,6 +44,7 @@ private:
QString checkoutUrl;
bool isTrial = false;
QString serviceProtocol;
QString storeProductId;
};
QVector<SubscriptionPlanItem> m_subscriptionPlans;

View File

@@ -219,6 +219,19 @@ PageType {
PageController.goToPage(PageEnum.PageSetupWizardApiTrialEmail)
return
}
if (Qt.platform.os === "ios" || IsMacOsNeBuild) {
PageController.showBusyIndicator(true)
var storeId = plan.storeProductId !== undefined ? String(plan.storeProductId) : ""
var ok = ApiConfigsController.importPremiumFromAppStore(storeId)
PageController.showBusyIndicator(false)
if (!ok) {
var endpoint = ApiServicesModel.getStoreEndpoint()
Qt.openUrlExternally(endpoint)
PageController.closePage()
PageController.closePage()
}
return
}
if (plan.checkoutUrl) {
Qt.openUrlExternally(plan.checkoutUrl)
PageController.closePage()
@@ -226,11 +239,11 @@ PageType {
return
}
PageController.showBusyIndicator(true)
var ok = ApiConfigsController.importService()
var importOk = ApiConfigsController.importService()
PageController.showBusyIndicator(false)
if (!ok) {
var endpoint = ApiServicesModel.getStoreEndpoint()
Qt.openUrlExternally(endpoint)
if (!importOk) {
var fallbackEndpoint = ApiServicesModel.getStoreEndpoint()
Qt.openUrlExternally(fallbackEndpoint)
PageController.closePage()
PageController.closePage()
}