mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-22 02:01:08 +07:00
feat: iap for apple now use storekit2
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
@available(iOS 15.0, macOS 12.0, *)
|
||||
@objcMembers
|
||||
public class StoreKit2Helper: NSObject {
|
||||
|
||||
public static let shared = StoreKit2Helper()
|
||||
|
||||
public func fetchCurrentEntitlements(completion: @escaping (Bool, [NSDictionary]?, NSError?) -> Void) {
|
||||
Task {
|
||||
var entitlements: [NSDictionary] = []
|
||||
do {
|
||||
for await result in Transaction.currentEntitlements {
|
||||
switch result {
|
||||
case .verified(let transaction):
|
||||
let info: NSDictionary = [
|
||||
"transactionId": String(transaction.id),
|
||||
"originalTransactionId": String(transaction.originalID),
|
||||
"productId": transaction.productID
|
||||
]
|
||||
entitlements.append(info)
|
||||
case .unverified(_, let error):
|
||||
print("[IAP][StoreKit2] Unverified transaction skipped: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async { completion(true, entitlements, nil) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func purchaseProduct(productIdentifier: String, completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
let products = try await Product.products(for: [productIdentifier])
|
||||
guard let product = products.first else {
|
||||
let error = NSError(domain: "StoreKit2Helper", code: 0, userInfo: [NSLocalizedDescriptionKey: "Product not found"])
|
||||
DispatchQueue.main.async { completion(false, nil, nil, nil, error) }
|
||||
return
|
||||
}
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
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) }
|
||||
case .unverified(_, let error):
|
||||
DispatchQueue.main.async { completion(false, nil, nil, nil, 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) }
|
||||
case .pending:
|
||||
let error = NSError(domain: "StoreKit2Helper", code: 2, userInfo: [NSLocalizedDescriptionKey: "Purchase pending"])
|
||||
DispatchQueue.main.async { completion(false, nil, nil, nil, error) }
|
||||
@unknown default:
|
||||
let error = NSError(domain: "StoreKit2Helper", code: 3, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase result"])
|
||||
DispatchQueue.main.async { completion(false, nil, nil, nil, error) }
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async { completion(false, nil, nil, nil, error as NSError) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func storefrontCurrencyCode(for product: Product) -> String {
|
||||
product.priceFormatStyle.locale.currencyCode ?? ""
|
||||
}
|
||||
|
||||
public func fetchProducts(identifiers: Set<String>, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
let products = try await Product.products(for: identifiers)
|
||||
let productDicts = products.map { product -> NSDictionary in
|
||||
let currencyCode = storefrontCurrencyCode(for: product)
|
||||
return [
|
||||
"productId": product.id,
|
||||
"title": product.displayName,
|
||||
"description": product.description,
|
||||
"price": "\(product.price)",
|
||||
"currencyCode": currencyCode
|
||||
]
|
||||
}
|
||||
let fetchedIds = Set(products.map { $0.id })
|
||||
let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) }
|
||||
DispatchQueue.main.async { completion(productDicts, Array(invalidIdentifiers), nil) }
|
||||
} catch {
|
||||
DispatchQueue.main.async { completion([], Array(identifiers), error as NSError) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,12 @@
|
||||
|
||||
#import "StoreKitController.h"
|
||||
#import <StoreKit/StoreKit.h>
|
||||
#import <AmneziaVPN-Swift.h>
|
||||
|
||||
#include <QtCore/QDebug>
|
||||
#include <QtCore/QString>
|
||||
|
||||
API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
@interface StoreKitController () <SKProductsRequestDelegate, SKPaymentTransactionObserver>
|
||||
@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success,
|
||||
NSString *_Nullable transactionId,
|
||||
NSString *_Nullable productId,
|
||||
NSString *_Nullable originalTransactionId,
|
||||
NSError *_Nullable error);
|
||||
@property (nonatomic, copy) void (^restoreCompletion)(BOOL success,
|
||||
NSArray<NSDictionary *> *_Nullable restoredTransactions,
|
||||
NSError *_Nullable error);
|
||||
@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray<NSDictionary *> *products,
|
||||
NSArray<NSString *> *invalidIdentifiers,
|
||||
NSError *_Nullable error);
|
||||
@property (nonatomic, strong) SKProductsRequest *productsRequest;
|
||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *restoredTransactions;
|
||||
@end
|
||||
|
||||
@implementation StoreKitController
|
||||
|
||||
+ (instancetype)sharedInstance
|
||||
@@ -42,17 +27,9 @@ API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
- (instancetype)init API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
|
||||
}
|
||||
|
||||
- (void)purchaseProduct:(NSString *)productIdentifier
|
||||
completion:(void (^)(BOOL success,
|
||||
NSString *_Nullable transactionId,
|
||||
@@ -60,41 +37,50 @@ API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
NSString *_Nullable originalTransactionId,
|
||||
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
{
|
||||
self.purchaseCompletion = completion;
|
||||
|
||||
qInfo().noquote() << "[IAP][StoreKit] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String);
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
[self performPurchaseAsync:productIdentifier];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)performPurchaseAsync:(NSString *)productIdentifier API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
@try {
|
||||
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]];
|
||||
request.delegate = self;
|
||||
[request start];
|
||||
|
||||
} @catch (NSException *exception) {
|
||||
NSError *error = [NSError errorWithDomain:@"StoreKitController"
|
||||
code:1
|
||||
userInfo:@{ NSLocalizedDescriptionKey : exception.reason ?: @"Purchase failed" }];
|
||||
if (self.purchaseCompletion) {
|
||||
self.purchaseCompletion(NO, nil, nil, nil, error);
|
||||
self.purchaseCompletion = nil;
|
||||
}
|
||||
qInfo().noquote() << "[IAP][StoreKit2] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String);
|
||||
[[StoreKit2Helper shared] purchaseProductWithProductIdentifier:productIdentifier
|
||||
completion:^(BOOL success,
|
||||
NSString *transactionId,
|
||||
NSString *productId,
|
||||
NSString *originalTransactionId,
|
||||
NSError *error) {
|
||||
if (success) {
|
||||
qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << QString::fromUtf8(transactionId.UTF8String)
|
||||
<< "originalTransactionId =" << QString::fromUtf8(originalTransactionId.UTF8String)
|
||||
<< "productId =" << QString::fromUtf8(productId.UTF8String);
|
||||
} else if (error) {
|
||||
qWarning().noquote() << "[IAP][StoreKit2] Purchase failed:" << QString::fromUtf8(error.localizedDescription.UTF8String);
|
||||
}
|
||||
});
|
||||
if (completion) {
|
||||
completion(success, transactionId, productId, originalTransactionId, error);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)restorePurchasesWithCompletion:(void (^)(BOOL success,
|
||||
NSArray<NSDictionary *> *_Nullable restoredTransactions,
|
||||
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
{
|
||||
self.restoreCompletion = completion;
|
||||
self.restoredTransactions = [NSMutableArray array];
|
||||
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
|
||||
[[StoreKit2Helper shared] fetchCurrentEntitlementsWithCompletion:^(BOOL success,
|
||||
NSArray<NSDictionary *> *entitlements,
|
||||
NSError *error) {
|
||||
if (success) {
|
||||
qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned"
|
||||
<< (int)(entitlements ? entitlements.count : 0) << "active entitlements";
|
||||
for (NSDictionary *info 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]);
|
||||
}
|
||||
} else {
|
||||
qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:"
|
||||
<< QString::fromUtf8(error.localizedDescription.UTF8String);
|
||||
}
|
||||
if (completion) {
|
||||
completion(success, entitlements, error);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)fetchProductsWithIdentifiers:(NSSet<NSString *> *)productIdentifiers
|
||||
@@ -102,163 +88,21 @@ API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
NSArray<NSString *> *invalidIdentifiers,
|
||||
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
||||
{
|
||||
self.productsFetchCompletion = completion;
|
||||
self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
|
||||
self.productsRequest.delegate = self;
|
||||
[self.productsRequest start];
|
||||
}
|
||||
|
||||
#pragma mark - SKProductsRequestDelegate / SKRequestDelegate
|
||||
|
||||
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
|
||||
{
|
||||
if (self.purchaseCompletion) {
|
||||
SKProduct *product = response.products.firstObject;
|
||||
if (!product) {
|
||||
NSError *error = [NSError errorWithDomain:@"StoreKitController"
|
||||
code:0
|
||||
userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }];
|
||||
self.purchaseCompletion(NO, nil, nil, nil, error);
|
||||
self.purchaseCompletion = nil;
|
||||
self.productsRequest = nil;
|
||||
return;
|
||||
}
|
||||
NSString *currencyCode = [product.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"";
|
||||
NSString *priceString = [product.price stringValue] ?: @"";
|
||||
qInfo().noquote() << "[IAP][StoreKit] Received product" << QString::fromUtf8(product.productIdentifier.UTF8String)
|
||||
<< "price=" << QString::fromUtf8(priceString.UTF8String)
|
||||
<< "currency=" << QString::fromUtf8(currencyCode.UTF8String);
|
||||
SKPayment *payment = [SKPayment paymentWithProduct:product];
|
||||
[[SKPaymentQueue defaultQueue] addPayment:payment];
|
||||
self.productsRequest = nil;
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.productsFetchCompletion) {
|
||||
NSMutableArray<NSDictionary *> *productDicts = [NSMutableArray array];
|
||||
for (SKProduct *p in response.products) {
|
||||
NSDictionary *productDict = @{
|
||||
@"productId": p.productIdentifier,
|
||||
@"title": p.localizedTitle,
|
||||
@"description": p.localizedDescription,
|
||||
@"price": p.price.stringValue,
|
||||
@"currencyCode": [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""
|
||||
};
|
||||
[productDicts addObject:productDict];
|
||||
NSString *productCurrency = [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"";
|
||||
NSString *productPrice = [p.price stringValue] ?: @"";
|
||||
qInfo().noquote() << "[IAP][StoreKit] Fetched product info" << QString::fromUtf8(p.productIdentifier.UTF8String)
|
||||
<< "price=" << QString::fromUtf8(productPrice.UTF8String)
|
||||
<< "currency=" << QString::fromUtf8(productCurrency.UTF8String);
|
||||
}
|
||||
|
||||
self.productsFetchCompletion(productDicts, response.invalidProductIdentifiers, nil);
|
||||
self.productsFetchCompletion = nil;
|
||||
self.productsRequest = nil;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
|
||||
{
|
||||
if (self.purchaseCompletion) {
|
||||
self.purchaseCompletion(NO, nil, nil, nil, error);
|
||||
self.purchaseCompletion = nil;
|
||||
}
|
||||
if (self.productsFetchCompletion) {
|
||||
self.productsFetchCompletion(@[], @[], error);
|
||||
self.productsFetchCompletion = nil;
|
||||
}
|
||||
self.productsRequest = nil;
|
||||
}
|
||||
|
||||
#pragma mark - SKPaymentTransactionObserver
|
||||
|
||||
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
|
||||
{
|
||||
for (SKPaymentTransaction *transaction in transactions) {
|
||||
switch (transaction.transactionState) {
|
||||
case SKPaymentTransactionStatePurchased: {
|
||||
NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transaction.transactionIdentifier;
|
||||
qInfo().noquote() << "[IAP][StoreKit] Transaction purchased" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String)
|
||||
<< "original=" << QString::fromUtf8((originalTransactionId ?: @"").UTF8String)
|
||||
<< "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String);
|
||||
|
||||
if (self.purchaseCompletion) {
|
||||
self.purchaseCompletion(YES,
|
||||
transaction.transactionIdentifier,
|
||||
transaction.payment.productIdentifier,
|
||||
originalTransactionId,
|
||||
nil);
|
||||
self.purchaseCompletion = nil;
|
||||
[[StoreKit2Helper shared] fetchProductsWithIdentifiers:productIdentifiers
|
||||
completion:^(NSArray<NSDictionary *> *products,
|
||||
NSArray<NSString *> *invalidIdentifiers,
|
||||
NSError *error) {
|
||||
if (!error) {
|
||||
for (NSDictionary *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]);
|
||||
}
|
||||
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
|
||||
break;
|
||||
}
|
||||
case SKPaymentTransactionStateFailed:
|
||||
qInfo().noquote() << "[IAP][StoreKit] Transaction failed" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String)
|
||||
<< "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String)
|
||||
<< "error=" << QString::fromUtf8(transaction.error.localizedDescription.UTF8String);
|
||||
if (self.purchaseCompletion) {
|
||||
self.purchaseCompletion(NO,
|
||||
transaction.transactionIdentifier,
|
||||
transaction.payment.productIdentifier,
|
||||
nil,
|
||||
transaction.error);
|
||||
self.purchaseCompletion = nil;
|
||||
}
|
||||
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
|
||||
break;
|
||||
case SKPaymentTransactionStateRestored: {
|
||||
if (self.restoreCompletion) {
|
||||
NSString *transactionId = transaction.transactionIdentifier ?: @"";
|
||||
NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transactionId;
|
||||
NSString *productId = transaction.payment.productIdentifier ?: @"";
|
||||
|
||||
qInfo().noquote() << "[IAP][StoreKit] Transaction restored"
|
||||
<< QString::fromUtf8(transactionId.UTF8String)
|
||||
<< "original="
|
||||
<< QString::fromUtf8((originalTransactionId ?: @"").UTF8String)
|
||||
<< "product="
|
||||
<< QString::fromUtf8((productId ?: @"").UTF8String);
|
||||
|
||||
NSDictionary *info = @{
|
||||
@"transactionId": transactionId,
|
||||
@"originalTransactionId": originalTransactionId ?: @"",
|
||||
@"productId": productId ?: @""
|
||||
};
|
||||
if (!self.restoredTransactions) {
|
||||
self.restoredTransactions = [NSMutableArray array];
|
||||
}
|
||||
[self.restoredTransactions addObject:info];
|
||||
}
|
||||
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
|
||||
break;
|
||||
if (completion) {
|
||||
completion(products ?: @[], invalidIdentifiers ?: @[], error);
|
||||
}
|
||||
case SKPaymentTransactionStatePurchasing:
|
||||
case SKPaymentTransactionStateDeferred:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
|
||||
{
|
||||
if (self.restoreCompletion) {
|
||||
NSArray<NSDictionary *> *transactions = [self.restoredTransactions copy];
|
||||
self.restoreCompletion(YES, transactions, nil);
|
||||
self.restoreCompletion = nil;
|
||||
self.restoredTransactions = nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error
|
||||
{
|
||||
if (self.restoreCompletion) {
|
||||
self.restoreCompletion(NO, nil, error);
|
||||
self.restoreCompletion = nil;
|
||||
self.restoredTransactions = nil;
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
Reference in New Issue
Block a user