mirror of
https://github.com/amnezia-vpn/DefaultVPN.git
synced 2026-05-17 08:36:37 +03:00
feat: ios in-app purchase methods (#1652)
* Add in-app purchase methods * fix: init StoreKit controller on startup * fix: Add transaction details to StoreKit callbacks * nullpointer access fixed * feat: in app purchase for ios * feat: add IAP product fetching and logging for iOS platform * feat: iOS Simulator building pipeline made * feat: add support for multiple IAP product IDs and attempt purchase of the first valid one * feat: add support for retrieving Base64-encoded app receipt after successful IAP purchase * refactor: inapp-purchase code cleanup * feat: iap processing * refactor: move to storekit 2 * feat: add request to billing * chore: add ios ifdef * feat: remove iOS simulator specific code and exclusions * refactor: remove unused StoreKit 2 transaction observer and simplify IAP product fetching logic * feat: implement StoreKit 2 for iOS and macOS, add restore purchases functionality * fix: Restore Purchases button appearance updated * feat: enhance error handling and duplicate config detection in ApiConfigsController * feat: add support for Mac OS NE in-app purchases and StoreKitController * ci-cd fix * Revert "ci-cd fix" This reverts commit f22fd7a13bb093205a81561e4e397d2075776646. --------- Co-authored-by: vladimir.kuznetsov <nethiuswork@gmail.com> Co-authored-by: vkamn <vk@amnezia.org> Co-authored-by: spectrum <yyy@amnezia.org>
This commit is contained in:
@@ -34,6 +34,7 @@ set(HEADERS ${HEADERS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h
|
||||
)
|
||||
set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h PROPERTIES OBJECTIVE_CPP_HEADER TRUE)
|
||||
@@ -46,6 +47,7 @@ set(SOURCES ${SOURCES}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ set(HEADERS ${HEADERS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h
|
||||
)
|
||||
@@ -45,6 +46,7 @@ set(SOURCES ${SOURCES}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
|
||||
|
||||
@@ -121,6 +121,7 @@ namespace amnezia
|
||||
ApiMigrationError = 1110,
|
||||
ApiUpdateRequestError = 1111,
|
||||
ApiSubscriptionExpiredError = 1112,
|
||||
ApiPurchaseError = 1113,
|
||||
|
||||
// QFile errors
|
||||
OpenError = 1200,
|
||||
|
||||
@@ -78,6 +78,7 @@ QString errorString(ErrorCode code) {
|
||||
case (ErrorCode::ApiMigrationError): errorMessage = QObject::tr("A migration error has occurred. Please contact our technical support"); break;
|
||||
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;
|
||||
|
||||
// QFile errors
|
||||
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
||||
|
||||
39
client/platforms/ios/StoreKitController.h
Normal file
39
client/platforms/ios/StoreKitController.h
Normal file
@@ -0,0 +1,39 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
#ifndef STOREKITCONTROLLER_H
|
||||
#define STOREKITCONTROLLER_H
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <StoreKit/StoreKit.h>
|
||||
|
||||
@class Product;
|
||||
@class Transaction;
|
||||
@class VerificationResult;
|
||||
|
||||
API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
@interface StoreKitController : NSObject
|
||||
|
||||
+ (instancetype)sharedInstance;
|
||||
|
||||
- (void)purchaseProduct:(NSString *)productIdentifier
|
||||
completion:(void (^)(BOOL success,
|
||||
NSString *_Nullable transactionId,
|
||||
NSString *_Nullable productId,
|
||||
NSString *_Nullable originalTransactionId,
|
||||
NSError *_Nullable error))completion;
|
||||
|
||||
- (void)restorePurchasesWithCompletion:(void (^)(BOOL success,
|
||||
NSArray<NSDictionary *> *_Nullable restoredTransactions,
|
||||
NSError *_Nullable error))completion;
|
||||
|
||||
// Fetch product information for a set of identifiers without initiating a purchase
|
||||
- (void)fetchProductsWithIdentifiers:(NSSet<NSString *> *)productIdentifiers
|
||||
completion:(void (^)(NSArray<NSDictionary *> *products,
|
||||
NSArray<NSString *> *invalidIdentifiers,
|
||||
NSError *_Nullable error))completion;
|
||||
|
||||
@end
|
||||
|
||||
#endif // STOREKITCONTROLLER_H
|
||||
264
client/platforms/ios/StoreKitController.mm
Normal file
264
client/platforms/ios/StoreKitController.mm
Normal file
@@ -0,0 +1,264 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
#import "StoreKitController.h"
|
||||
#import <StoreKit/StoreKit.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
|
||||
|
||||
@implementation StoreKitController
|
||||
|
||||
+ (instancetype)sharedInstance
|
||||
{
|
||||
static dispatch_once_t onceToken;
|
||||
static StoreKitController *instance;
|
||||
dispatch_once(&onceToken, ^{
|
||||
if (@available(iOS 15.0, macOS 12.0, *)) {
|
||||
instance = [[StoreKitController alloc] init];
|
||||
}
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
- (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,
|
||||
NSString *_Nullable productId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (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];
|
||||
}
|
||||
|
||||
- (void)fetchProductsWithIdentifiers:(NSSet<NSString *> *)productIdentifiers
|
||||
completion:(void (^)(NSArray<NSDictionary *> *products,
|
||||
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;
|
||||
}
|
||||
[[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;
|
||||
}
|
||||
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
|
||||
@@ -2,6 +2,11 @@
|
||||
#define IOS_CONTROLLER_H
|
||||
|
||||
#include "protocols/vpnprotocol.h"
|
||||
#include <functional>
|
||||
#include <QVariant>
|
||||
#include <QVariantMap>
|
||||
#include <QStringList>
|
||||
#include <QList>
|
||||
|
||||
#ifdef __OBJC__
|
||||
#import <Foundation/Foundation.h>
|
||||
@@ -55,6 +60,22 @@ public:
|
||||
bool shareText(const QStringList &filesToSend);
|
||||
QString openFile();
|
||||
|
||||
void purchaseProduct(const QString &productId,
|
||||
std::function<void(bool success,
|
||||
const QString &transactionId,
|
||||
const QString &purchasedProductId,
|
||||
const QString &originalTransactionId,
|
||||
const QString &errorString)> &&callback);
|
||||
void restorePurchases(std::function<void(bool success,
|
||||
const QList<QVariantMap> &transactions,
|
||||
const QString &errorString)> &&callback);
|
||||
|
||||
// Fetch product info for given product identifiers and return basic fields for logging
|
||||
void fetchProducts(const QStringList &productIds,
|
||||
std::function<void(const QList<QVariantMap> &products,
|
||||
const QStringList &invalidIds,
|
||||
const QString &errorString)> &&callback);
|
||||
|
||||
void requestInetAccess();
|
||||
signals:
|
||||
void connectionStateChanged(Vpn::ConnectionState state);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "../protocols/vpnprotocol.h"
|
||||
#import "ios_controller_wrapper.h"
|
||||
#import "StoreKitController.h"
|
||||
|
||||
const char* Action::start = "start";
|
||||
const char* Action::restart = "restart";
|
||||
@@ -101,6 +102,9 @@ IosController::IosController() : QObject()
|
||||
s_instance = this;
|
||||
m_iosControllerWrapper = [[IosControllerWrapper alloc] initWithCppController:this];
|
||||
|
||||
// Initialize StoreKitController early to start observing the payment queue
|
||||
[StoreKitController sharedInstance];
|
||||
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
removeObserver: (__bridge NSObject *)m_iosControllerWrapper];
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
@@ -909,6 +913,135 @@ QString IosController::openFile() {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
void IosController::purchaseProduct(const QString &productId,
|
||||
std::function<void(bool success,
|
||||
const QString &transactionId,
|
||||
const QString &purchasedProductId,
|
||||
const QString &originalTransactionId,
|
||||
const QString &errorString)> &&callback)
|
||||
{
|
||||
qInfo().noquote() << "[IAP][IosController] purchaseProduct called" << productId;
|
||||
if (@available(iOS 15.0, macOS 12.0, *)) {
|
||||
StoreKitController *controller = [StoreKitController sharedInstance];
|
||||
__block auto cb = std::move(callback);
|
||||
[controller purchaseProduct:productId.toNSString() completion:^(BOOL s,
|
||||
NSString * _Nullable transactionId,
|
||||
NSString * _Nullable prodId,
|
||||
NSString * _Nullable originalTxId,
|
||||
NSError * _Nullable error) {
|
||||
const QString txId = QString::fromUtf8((transactionId ?: @"").UTF8String);
|
||||
const QString pId = QString::fromUtf8((prodId ?: @"").UTF8String);
|
||||
const QString origTxId = QString::fromUtf8((originalTxId ?: @"").UTF8String);
|
||||
const QString err = QString::fromUtf8((error.localizedDescription ?: @"").UTF8String);
|
||||
|
||||
qInfo().noquote() << "[IAP][IosController] purchase completion" << "success=" << s
|
||||
<< "transactionId=" << txId << "originalTransactionId=" << origTxId
|
||||
<< "productId=" << pId << "error=" << err;
|
||||
|
||||
if (cb) {
|
||||
cb(s, txId, pId, origTxId, err);
|
||||
}
|
||||
}];
|
||||
} else {
|
||||
if (callback) {
|
||||
callback(false, QString(), QString(), QString(), "StoreKit 2 requires iOS 15.0 or later");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void IosController::restorePurchases(std::function<void(bool success,
|
||||
const QList<QVariantMap> &transactions,
|
||||
const QString &errorString)> &&callback)
|
||||
{
|
||||
if (@available(iOS 15.0, macOS 12.0, *)) {
|
||||
StoreKitController *controller = [StoreKitController sharedInstance];
|
||||
__block auto cb = std::move(callback);
|
||||
[controller restorePurchasesWithCompletion:^(BOOL s,
|
||||
NSArray<NSDictionary *> * _Nullable restoredTransactions,
|
||||
NSError * _Nullable error) {
|
||||
QString err;
|
||||
if (error) {
|
||||
err = QString::fromUtf8(error.localizedDescription.UTF8String);
|
||||
}
|
||||
QList<QVariantMap> transactions;
|
||||
for (NSDictionary *dict in restoredTransactions ?: @[]) {
|
||||
QVariantMap transaction;
|
||||
NSString *transactionId = dict[@"transactionId"];
|
||||
NSString *productId = dict[@"productId"];
|
||||
NSString *originalTransactionId = dict[@"originalTransactionId"];
|
||||
|
||||
if (transactionId) {
|
||||
transaction.insert(QStringLiteral("transactionId"), QString::fromUtf8(transactionId.UTF8String));
|
||||
}
|
||||
if (productId) {
|
||||
transaction.insert(QStringLiteral("productId"), QString::fromUtf8(productId.UTF8String));
|
||||
}
|
||||
if (originalTransactionId) {
|
||||
transaction.insert(QStringLiteral("originalTransactionId"),
|
||||
QString::fromUtf8(originalTransactionId.UTF8String));
|
||||
}
|
||||
transactions.push_back(transaction);
|
||||
}
|
||||
if (cb) {
|
||||
cb(s, transactions, err);
|
||||
}
|
||||
}];
|
||||
} else {
|
||||
if (callback) {
|
||||
callback(false, QList<QVariantMap>(), "StoreKit 2 requires iOS 15.0 or later");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void IosController::fetchProducts(const QStringList &productIds,
|
||||
std::function<void(const QList<QVariantMap> &products,
|
||||
const QStringList &invalidIds,
|
||||
const QString &errorString)> &&callback)
|
||||
{
|
||||
if (@available(iOS 15.0, macOS 12.0, *)) {
|
||||
StoreKitController *controller = [StoreKitController sharedInstance];
|
||||
NSMutableSet<NSString *> *ids = [NSMutableSet setWithCapacity:productIds.size()];
|
||||
for (const auto &pid : productIds) {
|
||||
[ids addObject:pid.toNSString()];
|
||||
}
|
||||
__block auto cb = std::move(callback);
|
||||
|
||||
[controller fetchProductsWithIdentifiers:ids
|
||||
completion:^(NSArray<NSDictionary *> * _Nonnull products,
|
||||
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);
|
||||
}
|
||||
|
||||
QStringList invalid;
|
||||
for (NSString *inv in invalidIdentifiers) {
|
||||
invalid.push_back(QString::fromUtf8(inv.UTF8String));
|
||||
}
|
||||
|
||||
QString err;
|
||||
if (error) {
|
||||
err = QString::fromUtf8(error.localizedDescription.UTF8String);
|
||||
}
|
||||
|
||||
if (cb) {
|
||||
cb(outProducts, invalid, err);
|
||||
}
|
||||
}];
|
||||
} else {
|
||||
if (callback) {
|
||||
callback(QList<QVariantMap>(), QStringList(), "StoreKit 2 requires iOS 15.0 or later");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void IosController::requestInetAccess() {
|
||||
NSURL *url = [NSURL URLWithString:@"http://captive.apple.com/generate_204"];
|
||||
if (!url) {
|
||||
|
||||
@@ -2849,6 +2849,11 @@ Already installed containers were found on the server. All installed containers
|
||||
<source>Site Amnezia</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="229"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>استعادة المشتريات</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="251"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -2973,6 +2973,11 @@ It's okay as long as it's from someone you trust.</source>
|
||||
<source>Site Amnezia</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="229"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>بازیابی خریدها</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="251"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -2865,6 +2865,11 @@ Already installed containers were found on the server. All installed containers
|
||||
<source>Site Amnezia</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="229"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>खरीदारी पुनर्स्थापित करें</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="251"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -2867,6 +2867,11 @@ Already installed containers were found on the server. All installed containers
|
||||
<source>Site Amnezia</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="229"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>ဝယ်ယူထားသည့်များကို ပြန်လည်ရယူမည်</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="251"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -3244,6 +3244,11 @@ Thank you for staying with us!</source>
|
||||
<source>Site Amnezia</source>
|
||||
<translation>Сайт Amnezia</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="254"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>Восстановить покупки</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="276"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -3131,6 +3131,11 @@ It's okay as long as it's from someone you trust.</source>
|
||||
<source>Site Amnezia</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="229"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>Відновити покупки</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="251"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -2857,6 +2857,11 @@ Already installed containers were found on the server. All installed containers
|
||||
<source>Site Amnezia</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="229"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>خریداری بحال کریں</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="251"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -3008,6 +3008,11 @@ It's okay as long as it's from someone you trust.</source>
|
||||
<source>Site Amnezia</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="229"/>
|
||||
<source>Restore purchases</source>
|
||||
<translation>恢复购买</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="251"/>
|
||||
<source>VPN by Amnezia</source>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#include "apiConfigsController.h"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QDebug>
|
||||
#include <QEventLoop>
|
||||
|
||||
#include <QSet>
|
||||
#include "amnezia_application.h"
|
||||
#include "configurators/wireguard_configurator.h"
|
||||
#include "core/api/apiDefs.h"
|
||||
@@ -12,6 +13,8 @@
|
||||
#include "ui/controllers/systemController.h"
|
||||
#include "version.h"
|
||||
|
||||
#include "platforms/ios/ios_controller.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
namespace configKey
|
||||
@@ -173,7 +176,7 @@ namespace
|
||||
auto clientProtocolConfig =
|
||||
QJsonDocument::fromJson(serverProtocolConfig.value(config_key::last_config).toString().toUtf8()).object();
|
||||
|
||||
// TODO looks like this block can be removed after v1 configs EOL
|
||||
//TODO looks like this block can be removed after v1 configs EOL
|
||||
|
||||
serverProtocolConfig[config_key::junkPacketCount] = clientProtocolConfig.value(config_key::junkPacketCount);
|
||||
serverProtocolConfig[config_key::junkPacketMinSize] = clientProtocolConfig.value(config_key::junkPacketMinSize);
|
||||
@@ -397,6 +400,259 @@ bool ApiConfigsController::fillAvailableServices()
|
||||
|
||||
QJsonObject data = QJsonDocument::fromJson(responseBody).object();
|
||||
m_apiServicesModel->updateModel(data);
|
||||
if (m_apiServicesModel->rowCount() > 0) {
|
||||
m_apiServicesModel->setServiceIndex(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ApiConfigsController::importSerivceFromAppStore()
|
||||
{
|
||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||
QString chosenProductId;
|
||||
{
|
||||
const QStringList productIds = { QStringLiteral("com.amnezia.amneziavpn.1_month"), QStringLiteral("com.amnezia.AmneziaVPN.6_month") };
|
||||
qInfo().noquote() << "[IAP] Fetching products" << productIds;
|
||||
|
||||
QList<QVariantMap> products;
|
||||
QString fetchError;
|
||||
QEventLoop waitFetch;
|
||||
IosController::Instance()->fetchProducts(productIds,
|
||||
[&](const QList<QVariantMap> &prods, const QStringList &invalid, const QString &err) {
|
||||
products = prods;
|
||||
fetchError = err;
|
||||
qInfo().noquote() << "[IAP] Fetch callback" << "invalid=" << invalid
|
||||
<< "error=" << err;
|
||||
waitFetch.quit();
|
||||
});
|
||||
waitFetch.exec();
|
||||
|
||||
qInfo().noquote() << "[IAP] Product fetch completed; success =" << fetchError.isEmpty()
|
||||
<< "returned =" << products.size() << "invalid =" << !fetchError.isEmpty();
|
||||
|
||||
if (fetchError.isEmpty() && !products.isEmpty()) {
|
||||
chosenProductId = products.first().value("productId").toString();
|
||||
}
|
||||
if (chosenProductId.isEmpty() && !productIds.isEmpty()) {
|
||||
chosenProductId = productIds.first();
|
||||
}
|
||||
qInfo().noquote() << "[IAP] Chosen product =" << chosenProductId;
|
||||
}
|
||||
|
||||
bool purchaseOk = false;
|
||||
QString originalTransactionId;
|
||||
QString storeTransactionId;
|
||||
QString storeProductId;
|
||||
QString purchaseError;
|
||||
QEventLoop waitPurchase;
|
||||
IosController::Instance()->purchaseProduct(chosenProductId,
|
||||
[&](bool success, const QString &txId, const QString &purchasedProductId,
|
||||
const QString &originalTxId, const QString &errorString) {
|
||||
purchaseOk = success;
|
||||
originalTransactionId = originalTxId;
|
||||
storeTransactionId = txId;
|
||||
storeProductId = purchasedProductId;
|
||||
purchaseError = errorString;
|
||||
waitPurchase.quit();
|
||||
});
|
||||
waitPurchase.exec();
|
||||
|
||||
if (!purchaseOk || originalTransactionId.isEmpty()) {
|
||||
qDebug() << "IAP purchase failed:" << purchaseError;
|
||||
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||
return false;
|
||||
}
|
||||
qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId
|
||||
<< "originalTransactionId =" << originalTransactionId
|
||||
<< "productId =" << storeProductId;
|
||||
|
||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||
QString(APP_VERSION),
|
||||
m_settings->getInstallationUuid(true),
|
||||
m_apiServicesModel->getCountryCode(),
|
||||
"",
|
||||
m_apiServicesModel->getSelectedServiceType(),
|
||||
m_apiServicesModel->getSelectedServiceProtocol(),
|
||||
QJsonObject() };
|
||||
|
||||
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||
apiPayload[apiDefs::key::transactionId] = originalTransactionId;
|
||||
qInfo().noquote() << "[IAP] Sending subscription request. Payload:"
|
||||
<< QJsonDocument(apiPayload).toJson(QJsonDocument::Compact);
|
||||
|
||||
ErrorCode errorCode;
|
||||
QByteArray responseBody;
|
||||
errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody);
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
emit errorOccurred(errorCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
ErrorCode installError = ErrorCode::NoError;
|
||||
if (!installServerFromSubscriptionResponse(responseBody, &installError)) {
|
||||
const ErrorCode errorToEmit = installError == ErrorCode::NoError ? ErrorCode::ApiPurchaseError : installError;
|
||||
emit errorOccurred(errorToEmit);
|
||||
return false;
|
||||
}
|
||||
|
||||
qInfo().noquote() << "[IAP] Subscription config installed after purchase";
|
||||
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ApiConfigsController::restoreSerivceFromAppStore()
|
||||
{
|
||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
||||
const QString originalServiceType = m_apiServicesModel->rowCount() > 0 ? m_apiServicesModel->getSelectedServiceType() : QString();
|
||||
|
||||
if (m_apiServicesModel->rowCount() <= 0) {
|
||||
qInfo().noquote() << "[IAP] Services model is empty before restore, requesting available services";
|
||||
if (!fillAvailableServices()) {
|
||||
qWarning().noquote() << "[IAP] Unable to fetch services list before restore";
|
||||
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (m_apiServicesModel->rowCount() <= 0) {
|
||||
qWarning().noquote() << "[IAP] Restore aborted: services list is still empty";
|
||||
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||
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) {
|
||||
m_apiServicesModel->setServiceIndex(0);
|
||||
}
|
||||
|
||||
bool restoreSuccess = false;
|
||||
QList<QVariantMap> restoredTransactions;
|
||||
QString restoreError;
|
||||
QEventLoop waitRestore;
|
||||
|
||||
IosController::Instance()->restorePurchases([&](bool success,
|
||||
const QList<QVariantMap> &transactions,
|
||||
const QString &errorString) {
|
||||
restoreSuccess = success;
|
||||
restoredTransactions = transactions;
|
||||
restoreError = errorString;
|
||||
waitRestore.quit();
|
||||
});
|
||||
waitRestore.exec();
|
||||
|
||||
if (!restoreSuccess) {
|
||||
qWarning().noquote() << "[IAP] Restore failed:" << restoreError;
|
||||
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (restoredTransactions.isEmpty()) {
|
||||
qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned";
|
||||
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasInstalledConfig = false;
|
||||
bool duplicateConfigAlreadyPresent = false;
|
||||
int duplicateCount = 0;
|
||||
QSet<QString> processedTransactions;
|
||||
for (const QVariantMap &transaction : restoredTransactions) {
|
||||
const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString();
|
||||
const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString();
|
||||
const QString productId = transaction.value(QStringLiteral("productId")).toString();
|
||||
|
||||
if (originalTransactionId.isEmpty()) {
|
||||
qWarning().noquote() << "[IAP] Skipping restored transaction without originalTransactionId"
|
||||
<< transactionId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processedTransactions.contains(originalTransactionId)) {
|
||||
duplicateCount++;
|
||||
continue;
|
||||
}
|
||||
processedTransactions.insert(originalTransactionId);
|
||||
|
||||
qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId
|
||||
<< "originalTransactionId =" << originalTransactionId
|
||||
<< "productId =" << productId;
|
||||
|
||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||
QString(APP_VERSION),
|
||||
m_settings->getInstallationUuid(true),
|
||||
m_apiServicesModel->getCountryCode(),
|
||||
"",
|
||||
m_apiServicesModel->getSelectedServiceType(),
|
||||
m_apiServicesModel->getSelectedServiceProtocol(),
|
||||
QJsonObject() };
|
||||
|
||||
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||
apiPayload[apiDefs::key::transactionId] = originalTransactionId;
|
||||
|
||||
QByteArray responseBody;
|
||||
ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody);
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
qWarning().noquote() << "[IAP] Failed to restore transaction" << originalTransactionId
|
||||
<< "errorCode =" << static_cast<int>(errorCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
ErrorCode installError = ErrorCode::NoError;
|
||||
if (!installServerFromSubscriptionResponse(responseBody, &installError)) {
|
||||
if (installError == ErrorCode::ApiConfigAlreadyAdded) {
|
||||
duplicateConfigAlreadyPresent = true;
|
||||
qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId
|
||||
<< "because subscription config with the same vpn_key already exists";
|
||||
} else {
|
||||
qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction"
|
||||
<< originalTransactionId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
hasInstalledConfig = true;
|
||||
}
|
||||
|
||||
if (!hasInstalledConfig) {
|
||||
const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError;
|
||||
emit errorOccurred(restoreError);
|
||||
// Restore previous selection so that start page state is unchanged.
|
||||
if (!originalServiceType.isEmpty()) {
|
||||
for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) {
|
||||
m_apiServicesModel->setServiceIndex(i);
|
||||
if (m_apiServicesModel->getSelectedServiceType() == originalServiceType) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
|
||||
// Restore previous selection if it differs from premium
|
||||
if (!originalServiceType.isEmpty() && originalServiceType != premiumServiceType) {
|
||||
for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) {
|
||||
m_apiServicesModel->setServiceIndex(i);
|
||||
if (m_apiServicesModel->getSelectedServiceType() == originalServiceType) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -423,8 +679,10 @@ bool ApiConfigsController::importServiceFromGateway()
|
||||
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||
appendProtocolDataToApiPayload(gatewayRequestData.serviceProtocol, protocolData, apiPayload);
|
||||
|
||||
ErrorCode errorCode;
|
||||
QByteArray responseBody;
|
||||
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody);
|
||||
|
||||
errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody);
|
||||
|
||||
QJsonObject serverConfig;
|
||||
if (errorCode == ErrorCode::NoError) {
|
||||
@@ -706,7 +964,7 @@ QList<QString> ApiConfigsController::getQrCodes()
|
||||
|
||||
int ApiConfigsController::getQrCodesCount()
|
||||
{
|
||||
return m_qrCodes.size();
|
||||
return static_cast<int>(m_qrCodes.size());
|
||||
}
|
||||
|
||||
QString ApiConfigsController::getVpnKey()
|
||||
@@ -714,6 +972,92 @@ QString ApiConfigsController::getVpnKey()
|
||||
return m_vpnKey;
|
||||
}
|
||||
|
||||
bool ApiConfigsController::installServerFromSubscriptionResponse(const QByteArray &responseBody, ErrorCode *errorOut)
|
||||
{
|
||||
#ifdef Q_OS_IOS
|
||||
if (errorOut) {
|
||||
*errorOut = ErrorCode::NoError;
|
||||
}
|
||||
QJsonParseError parseError {};
|
||||
QJsonDocument responseDoc = QJsonDocument::fromJson(responseBody, &parseError);
|
||||
if (parseError.error == QJsonParseError::NoError) {
|
||||
qInfo().noquote() << "[IAP] Subscription raw response" << responseDoc.toJson(QJsonDocument::Compact);
|
||||
} else {
|
||||
qWarning().noquote() << "[IAP] Subscription raw response parse error:" << parseError.errorString()
|
||||
<< "raw=" << QString::fromUtf8(responseBody);
|
||||
}
|
||||
|
||||
const QJsonObject responseObject = responseDoc.object();
|
||||
QString key = responseObject.value(QStringLiteral("key")).toString();
|
||||
if (key.isEmpty()) {
|
||||
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
|
||||
if (errorOut) {
|
||||
*errorOut = ErrorCode::ApiPurchaseError;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_serversModel->hasServerWithVpnKey(key)) {
|
||||
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
|
||||
if (errorOut) {
|
||||
*errorOut = ErrorCode::ApiConfigAlreadyAdded;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QString normalizedKey = key;
|
||||
normalizedKey.replace(QStringLiteral("vpn://"), QString());
|
||||
|
||||
QByteArray config = QByteArray::fromBase64(normalizedKey.toUtf8(),
|
||||
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
||||
QByteArray configUncompressed = qUncompress(config);
|
||||
if (!configUncompressed.isEmpty()) {
|
||||
config = configUncompressed;
|
||||
}
|
||||
if (config.isEmpty()) {
|
||||
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
|
||||
if (errorOut) {
|
||||
*errorOut = ErrorCode::ApiPurchaseError;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError configParseError {};
|
||||
QJsonDocument configDoc = QJsonDocument::fromJson(config, &configParseError);
|
||||
if (configParseError.error != QJsonParseError::NoError) {
|
||||
qWarning().noquote() << "[IAP] Failed to parse subscription config:" << configParseError.errorString();
|
||||
if (errorOut) {
|
||||
*errorOut = ErrorCode::ApiPurchaseError;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject configJson = configDoc.object();
|
||||
|
||||
quint16 crc = qChecksum(QJsonDocument(configJson).toJson());
|
||||
auto apiConfig = configJson.value(apiDefs::key::apiConfig).toObject();
|
||||
apiConfig[apiDefs::key::vpnKey] = normalizedKey;
|
||||
auto subscriptionObject = apiConfig.value(configKey::subscription).toObject();
|
||||
qInfo().noquote() << "[IAP] Subscription payload details" << "serviceType="
|
||||
<< apiConfig.value(configKey::serviceType).toString()
|
||||
<< "serviceProtocol=" << apiConfig.value(configKey::serviceProtocol).toString()
|
||||
<< "subscriptionEnd=" << subscriptionObject.value(apiDefs::key::subscriptionEndDate).toString()
|
||||
<< "subscriptionType=" << subscriptionObject.value(QStringLiteral("type")).toString();
|
||||
configJson.insert(apiDefs::key::apiConfig, apiConfig);
|
||||
configJson.insert(config_key::crc, crc);
|
||||
m_serversModel->addServer(configJson);
|
||||
|
||||
qDebug() << configJson;
|
||||
return true;
|
||||
#else
|
||||
Q_UNUSED(responseBody)
|
||||
if (errorOut) {
|
||||
*errorOut = ErrorCode::ApiPurchaseError;
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
ErrorCode ApiConfigsController::executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody)
|
||||
{
|
||||
GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs,
|
||||
|
||||
@@ -26,6 +26,8 @@ public slots:
|
||||
void copyVpnKeyToClipboard();
|
||||
|
||||
bool fillAvailableServices();
|
||||
bool importSerivceFromAppStore();
|
||||
bool restoreSerivceFromAppStore();
|
||||
bool importServiceFromGateway();
|
||||
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
||||
bool reloadServiceConfig = false);
|
||||
@@ -54,6 +56,7 @@ private:
|
||||
QString getVpnKey();
|
||||
|
||||
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody);
|
||||
bool installServerFromSubscriptionResponse(const QByteArray &responseBody, ErrorCode *errorOut = nullptr);
|
||||
|
||||
QList<QString> m_qrCodes;
|
||||
QString m_vpnKey;
|
||||
|
||||
@@ -26,6 +26,15 @@ namespace
|
||||
constexpr char publicKeyInfo[] = "public_key";
|
||||
constexpr char expiresAt[] = "expires_at";
|
||||
}
|
||||
|
||||
QString normalizeVpnKey(const QString &vpnKey)
|
||||
{
|
||||
QString normalized = vpnKey.trimmed();
|
||||
if (normalized.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
|
||||
normalized = normalized.mid(QStringLiteral("vpn://").size());
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
ServersModel::ServersModel(std::shared_ptr<Settings> settings, QObject *parent) : m_settings(settings), QAbstractListModel(parent)
|
||||
@@ -718,6 +727,23 @@ bool ServersModel::isServerFromApiAlreadyExists(const QString &userCountryCode,
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ServersModel::hasServerWithVpnKey(const QString &vpnKey) const
|
||||
{
|
||||
const QString normalizedInput = normalizeVpnKey(vpnKey);
|
||||
if (normalizedInput.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const auto &server : std::as_const(m_servers)) {
|
||||
const auto apiConfig = server.toObject().value(configKey::apiConfig).toObject();
|
||||
const QString existingKey = normalizeVpnKey(apiConfig.value(apiDefs::key::vpnKey).toString());
|
||||
if (!existingKey.isEmpty() && existingKey == normalizedInput) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ServersModel::serverHasInstalledContainers(const int serverIndex) const
|
||||
{
|
||||
QJsonObject server = m_servers.at(serverIndex).toObject();
|
||||
|
||||
@@ -140,6 +140,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;
|
||||
|
||||
QVariant getDefaultServerData(const QString roleString);
|
||||
|
||||
|
||||
@@ -106,14 +106,18 @@ PageType {
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
text: qsTr("Connect")
|
||||
|
||||
clickedFunc: function() {
|
||||
var endpoint = ApiServicesModel.getStoreEndpoint()
|
||||
if (endpoint !== undefined && endpoint !== "") {
|
||||
Qt.openUrlExternally(endpoint)
|
||||
PageController.closePage()
|
||||
PageController.closePage()
|
||||
text: qsTr("Connect")
|
||||
|
||||
clickedFunc: function() {
|
||||
var endpoint = ApiServicesModel.getStoreEndpoint()
|
||||
if (endpoint !== undefined && endpoint !== "" && Qt.platform.os !== "ios" && !IsMacOsNeBuild) {
|
||||
Qt.openUrlExternally(endpoint)
|
||||
PageController.closePage()
|
||||
PageController.closePage()
|
||||
} else if (Qt.platform.os === "ios" || IsMacOsNeBuild) {
|
||||
PageController.showBusyIndicator(true)
|
||||
ApiConfigsController.importSerivceFromAppStore()
|
||||
PageController.showBusyIndicator(false)
|
||||
} else {
|
||||
PageController.showBusyIndicator(true)
|
||||
ApiConfigsController.importServiceFromGateway()
|
||||
|
||||
@@ -242,7 +242,7 @@ PageType {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
implicitHeight: 32
|
||||
|
||||
visible: Qt.platform.os !== "ios"
|
||||
visible: Qt.platform.os !== "ios" && !IsMacOsNeBuild
|
||||
|
||||
defaultColor: AmneziaStyle.color.transparent
|
||||
hoveredColor: AmneziaStyle.color.translucentWhite
|
||||
@@ -267,6 +267,7 @@ PageType {
|
||||
backupRestore,
|
||||
fileOpen,
|
||||
qrScan,
|
||||
restorePurchases,
|
||||
siteLink
|
||||
]
|
||||
|
||||
@@ -351,13 +352,27 @@ PageType {
|
||||
}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: restorePurchases
|
||||
|
||||
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()
|
||||
PageController.showBusyIndicator(false)
|
||||
}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: siteLink
|
||||
|
||||
property string title: qsTr("I have nothing")
|
||||
property string description: qsTr("")
|
||||
property string imageSource: "qrc:/images/controls/help-circle.svg"
|
||||
property bool isVisible: PageController.isStartPageVisible() && Qt.platform.os !== "ios"
|
||||
property bool isVisible: PageController.isStartPageVisible() && Qt.platform.os !== "ios" && !IsMacOsNeBuild
|
||||
property var handler: function() {
|
||||
Qt.openUrlExternally(LanguageModel.getCurrentSiteUrl())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user