lib_unity_purchase/Plugins/UnityPurchasing/iOS/UnityPurchasing.m

1194 lines
43 KiB
Objective-C

#import "UnityPurchasing.h"
#if MAC_APPSTORE
#import "Base64.h"
#endif
#if !MAC_APPSTORE
#import "UnityEarlyTransactionObserver.h"
#endif
@implementation ProductDefinition
@synthesize id;
@synthesize storeSpecificId;
@synthesize type;
@end
void UnityPurchasingLog(NSString *format, ...)
{
va_list args;
va_start(args, format);
NSString *message = [[NSString alloc] initWithFormat: format arguments: args];
va_end(args);
NSLog(@"UnityIAP: %@", message);
}
@implementation ReceiptRefresher
- (id)initWithCallback:(void (^)(BOOL, NSString*))callbackBlock
{
self.callback = callbackBlock;
return [super init];
}
- (void)requestDidFinish:(SKRequest *)request
{
self.callback(true, NULL);
}
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
NSString* errorMessage = [NSString stringWithFormat: @"Error code: %ld, error description: %@", error.code, error.description];
self.callback(false, errorMessage);
}
@end
#if !MAC_APPSTORE
@interface UnityPurchasing ()<UnityEarlyTransactionObserverDelegate>
@end
#endif
@implementation UnityPurchasing
// The max time we wait in between retrying failed SKProductRequests.
static const int MAX_REQUEST_PRODUCT_RETRY_DELAY = 60;
// The currency code for unknown locales, from https://en.wikipedia.org/wiki/ISO_4217#X_currencies
static const NSString* ISO_CURRENCY_CODE_UNKNOWN = @"XXX";
// Track our accumulated delay.
int delayInSeconds = 2;
- (NSString*)getAppReceipt
{
NSBundle* bundle = [NSBundle mainBundle];
if ([bundle respondsToSelector: @selector(appStoreReceiptURL)])
{
NSURL *receiptURL = [bundle appStoreReceiptURL];
if ([[NSFileManager defaultManager] fileExistsAtPath: [receiptURL path]])
{
NSData *receipt = [NSData dataWithContentsOfURL: receiptURL];
#if MAC_APPSTORE
// The base64EncodedStringWithOptions method was only added in OSX 10.9.
NSString* result = [receipt mgb64_base64EncodedString];
#else
NSString* result = [receipt base64EncodedStringWithOptions: 0];
#endif
return result;
}
}
UnityPurchasingLog(@"No App Receipt found");
return @"";
}
- (NSDate*)getAppReceiptModificationDate
{
NSBundle* bundle = [NSBundle mainBundle];
if ([bundle respondsToSelector: @selector(appStoreReceiptURL)])
{
NSURL *receiptURL = [bundle appStoreReceiptURL];
if ([[NSFileManager defaultManager] fileExistsAtPath: [receiptURL path]])
{
NSDate *modDate = [[[NSFileManager defaultManager] attributesOfItemAtPath: [receiptURL path] error: nil] fileModificationDate];
return modDate;
}
}
UnityPurchasingLog(@"No App Receipt found");
return nil;
}
- (NSString*)getTransactionReceiptForProductId:(NSString *)productId
{
NSString *result = transactionReceipts[productId];
if (!result)
{
UnityPurchasingLog(@"No Transaction Receipt found for product %@", productId);
}
return result ? : @"";
}
- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload
{
messageCallback(subject.UTF8String, payload.UTF8String, @"".UTF8String, @"".UTF8String, @"".UTF8String, false);
}
- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload receipt:(NSString*)receipt
{
messageCallback(subject.UTF8String, payload.UTF8String, receipt.UTF8String, @"".UTF8String, @"".UTF8String, false);
}
- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload receipt:(NSString*)receipt transactionId:(NSString*)transactionId originalTransactionId:(NSString*)originalTransactionId isRestored:(Boolean)isRestored
{
messageCallback(subject.UTF8String, payload.UTF8String, receipt.UTF8String, transactionId.UTF8String, originalTransactionId.UTF8String, isRestored);
}
- (void)setCallback:(UnityPurchasingCallback)callback
{
messageCallback = callback;
}
#if !MAC_APPSTORE
- (BOOL)isiOS6OrEarlier
{
float version = [[[UIDevice currentDevice] systemVersion] floatValue];
return version < 7;
}
#endif
// Retrieve a receipt for the transaction, which will either
// be the old style transaction receipt on <= iOS 6,
// or the App Receipt in OSX and iOS 7+.
- (NSString*)selectReceipt:(SKPaymentTransaction*)transaction
{
#if MAC_APPSTORE
return @"";
#else
if ([self isiOS6OrEarlier])
{
if (nil == transaction)
{
return @"";
}
NSString* receipt;
receipt = [[NSString alloc] initWithData: transaction.transactionReceipt encoding: NSUTF8StringEncoding];
return receipt;
}
else
{
return @"";
}
#endif
}
- (void)refreshReceipt
{
#if !MAC_APPSTORE
if ([self isiOS6OrEarlier])
{
UnityPurchasingLog(@"RefreshReceipt not supported on iOS < 7!");
return;
}
#endif
self.receiptRefresher = [[ReceiptRefresher alloc] initWithCallback:^(BOOL success, NSString* errorMessage) {
if (success)
{
UnityPurchasingLog(@"RefreshReceipt status %d", success);
[self UnitySendMessage: @"onAppReceiptRefreshed" payload: [self getAppReceipt]];
}
else
{
UnityPurchasingLog(@"RefreshReceipt status %d - Error message: %@", success, errorMessage);
[self UnitySendMessage: @"onAppReceiptRefreshFailed" payload: errorMessage];
}
}];
self.refreshRequest = [[SKReceiptRefreshRequest alloc] init];
self.refreshRequest.delegate = self.receiptRefresher;
[self.refreshRequest start];
}
// Handle a new or restored purchase transaction by informing Unity.
- (void)onTransactionSucceeded:(SKPaymentTransaction*)transaction isRestored:(Boolean)isRestored
{
NSString* transactionId = transaction.transactionIdentifier;
NSString* originalTransactionId = transaction.originalTransaction.transactionIdentifier;
// This should never happen according to Apple's docs, but it does!
if (nil == transactionId)
{
// Make something up, allowing us to identifiy the transaction when finishing it.
transactionId = [[NSUUID UUID] UUIDString];
UnityPurchasingLog(@"Missing transaction Identifier!");
}
// This transaction was marked as finished, but was not cleared from the queue. Try to clear it now, then pass the error up the stack as a DuplicateTransaction
if ([finishedTransactions containsObject: transactionId])
{
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
UnityPurchasingLog(@"DuplicateTransaction error with product %@ and transactionId %@", transaction.payment.productIdentifier, transactionId);
[self onPurchaseFailed: transaction.payment.productIdentifier reason: @"DuplicateTransaction" errorCode: @"" errorDescription: @"Duplicate transaction occurred"];
return; // EARLY RETURN
}
// Item was successfully purchased or restored.
if (nil == [pendingTransactions objectForKey: transactionId])
{
[pendingTransactions setObject: transaction forKey: transactionId];
}
[self UnitySendMessage: @"OnPurchaseSucceeded" payload: transaction.payment.productIdentifier receipt: [self selectReceipt: transaction] transactionId: transactionId originalTransactionId: originalTransactionId isRestored: isRestored];
}
// Called back by managed code when the transaction has been logged.
- (void)finishTransaction:(NSString *)transactionIdentifier hasProduct:(Boolean)hasProduct
{
SKPaymentTransaction* transaction = [pendingTransactions objectForKey: transactionIdentifier];
if (nil != transaction)
{
if (hasProduct)
{
UnityPurchasingLog(@"Finishing transaction %@", transactionIdentifier);
}
[[SKPaymentQueue defaultQueue] finishTransaction: transaction]; // If this fails (user not logged into the store?), transaction is already removed from pendingTransactions, so future calls to finishTransaction will not retry
[pendingTransactions removeObjectForKey: transactionIdentifier];
[finishedTransactions addObject: transactionIdentifier];
}
else
{
UnityPurchasingLog(@"Transaction %@ not pending, nothing to finish here", transactionIdentifier);
}
}
// Request information about our products from Apple.
- (void)requestProducts:(NSSet*)paramIds
{
productIds = paramIds;
UnityPurchasingLog(@"Requesting %lu products", (unsigned long)[productIds count]);
// Start an immediate poll.
[self initiateProductPoll: 0];
}
// Execute a product metadata retrieval request via GCD.
- (void)initiateProductPoll:(int)delayInSeconds
{
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^(void) {
UnityPurchasingLog(@"Requesting product data...");
request = [[SKProductsRequest alloc] initWithProductIdentifiers: productIds];
request.delegate = self;
[request start];
});
}
// Called by managed code when a user requests a purchase.
- (void)purchaseProduct:(ProductDefinition*)productDef
{
// Look up our corresponding product.
SKProduct* requestedProduct = [validProducts objectForKey: productDef.storeSpecificId];
if (requestedProduct != nil)
{
UnityPurchasingLog(@"PurchaseProduct: %@", requestedProduct.productIdentifier);
if ([SKPaymentQueue canMakePayments])
{
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct: requestedProduct];
// Modify payment request for testing ask-to-buy
if (_simulateAskToBuyEnabled)
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
if ([payment respondsToSelector: @selector(setSimulatesAskToBuyInSandbox:)])
{
UnityPurchasingLog(@"Queueing payment request with simulatesAskToBuyInSandbox enabled");
[payment performSelector: @selector(setSimulatesAskToBuyInSandbox:) withObject: @YES];
//payment.simulatesAskToBuyInSandbox = YES;
}
#pragma clang diagnostic pop
}
// Modify payment request with "applicationUsername" for fraud detection
if (_applicationUsername != nil)
{
if ([payment respondsToSelector: @selector(setApplicationUsername:)])
{
UnityPurchasingLog(@"Setting applicationUsername to %@", _applicationUsername);
[payment performSelector: @selector(setApplicationUsername:) withObject: _applicationUsername];
//payment.applicationUsername = _applicationUsername;
}
}
[[SKPaymentQueue defaultQueue] addPayment: payment];
}
else
{
UnityPurchasingLog(@"PurchaseProduct: IAP Disabled");
[self onPurchaseFailed: productDef.storeSpecificId reason: @"PurchasingUnavailable" errorCode: @"SKErrorPaymentNotAllowed" errorDescription: @"User is not authorized to make payments"];
}
}
else
{
[self onPurchaseFailed: productDef.storeSpecificId reason: @"ItemUnavailable" errorCode: @"" errorDescription: @"Unity IAP could not find requested product"];
}
}
// Initiate a request to Apple to restore previously made purchases.
- (void)restorePurchases
{
UnityPurchasingLog(@"RestorePurchase");
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
}
// A transaction observer should be added at startup (by managed code)
// and maintained for the life of the app, since transactions can
// be delivered at any time.
- (void)addTransactionObserver
{
SKPaymentQueue* defaultQueue = [SKPaymentQueue defaultQueue];
// Detect whether an existing transaction observer is in place.
// An existing observer will have processed any transactions already pending,
// so when we add our own storekit will not call our updatedTransactions handler.
// We workaround this by explicitly processing any existing transactions if they exist.
BOOL processExistingTransactions = false;
if (defaultQueue != nil && defaultQueue.transactions != nil)
{
if ([[defaultQueue transactions] count] > 0)
{
processExistingTransactions = true;
}
}
[defaultQueue addTransactionObserver: self];
if (processExistingTransactions)
{
[self paymentQueue: defaultQueue updatedTransactions: defaultQueue.transactions];
}
#if !MAC_APPSTORE
UnityEarlyTransactionObserver *observer = [UnityEarlyTransactionObserver defaultObserver];
if (observer)
{
observer.readyToReceiveTransactionUpdates = YES;
if (self.interceptPromotionalPurchases)
{
observer.delegate = self;
}
else
{
[observer initiateQueuedPayments];
}
}
#endif
}
- (void)initiateQueuedEarlyTransactionObserverPayments
{
#if !MAC_APPSTORE
[[UnityEarlyTransactionObserver defaultObserver] initiateQueuedPayments];
#endif
}
- (void)presentCodeRedemptionSheet
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 && TARGET_OS_TV == 0 && !MAC_APPSTORE
if (@available(iOS 14, *))
{
[[SKPaymentQueue defaultQueue] presentCodeRedemptionSheet];
}
else
#endif
{
UnityPurchasingLog(@"Offer Code redemption is available on iOS and iPadOS 14 and later");
}
}
#if !MAC_APPSTORE
#pragma mark -
#pragma mark UnityEarlyTransactionObserverDelegate Methods
- (void)promotionalPurchaseAttempted:(SKPayment *)payment
{
UnityPurchasingLog(@"Promotional purchase attempted");
[self UnitySendMessage: @"onPromotionalPurchaseAttempted" payload: payment.productIdentifier];
}
#endif
#pragma mark -
#pragma mark SKProductsRequestDelegate Methods
// Store Kit returns a response from an SKProductsRequest.
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
UnityPurchasingLog(@"Received %lu products", (unsigned long)[response.products count]);
// Add the retrieved products to our set of valid products.
NSDictionary* fetchedProducts = [NSDictionary dictionaryWithObjects: response.products forKeys: [response.products valueForKey: @"productIdentifier"]];
[validProducts addEntriesFromDictionary: fetchedProducts];
NSString* productJSON = [UnityPurchasing serializeProductMetadata: response.products];
// Send the app receipt as a separate parameter to avoid JSON parsing a large string.
[self UnitySendMessage: @"OnProductsRetrieved" payload: productJSON];
}
#pragma mark -
#pragma mark SKPaymentTransactionObserver Methods
// A product metadata retrieval request failed.
// We handle it by retrying at an exponentially increasing interval.
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
delayInSeconds = MIN(MAX_REQUEST_PRODUCT_RETRY_DELAY, 2 * delayInSeconds);
UnityPurchasingLog(@"SKProductRequest::didFailWithError: %ld, %@. Unity Purchasing will retry in %i seconds", (long)error.code, error.description, delayInSeconds);
[self initiateProductPoll: delayInSeconds];
}
- (void)requestDidFinish:(SKRequest *)req
{
request = nil;
}
- (void)onPurchaseFailed:(NSString*)productId reason:(NSString*)reason errorCode:(NSString*)errorCode errorDescription:(NSString*)errorDescription
{
NSMutableDictionary* dic = [[NSMutableDictionary alloc] init];
[dic setObject: productId forKey: @"productId"];
[dic setObject: reason forKey: @"reason"];
[dic setObject: errorCode forKey: @"storeSpecificErrorCode"];
[dic setObject: errorDescription forKey: @"message"];
NSData* data = [NSJSONSerialization dataWithJSONObject: dic options: 0 error: nil];
NSString* result = [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];
[self UnitySendMessage: @"OnPurchaseFailed" payload: result];
}
- (NSString*)purchaseErrorCodeToReason:(NSInteger)errorCode
{
switch (errorCode)
{
case SKErrorPaymentCancelled:
return @"UserCancelled";
case SKErrorPaymentInvalid:
return @"PaymentDeclined";
case SKErrorPaymentNotAllowed:
return @"PurchasingUnavailable";
}
return @"Unknown";
}
// The transaction status of the SKPaymentQueue is sent here.
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
UnityPurchasingLog(@"UpdatedTransactions");
for (SKPaymentTransaction *transaction in transactions)
{
[self handleTransaction: transaction];
}
}
// Called when one or more transactions have been removed from the queue.
- (void)paymentQueue:(SKPaymentQueue *)queue removedTransactions:(NSArray *)transactions
{
// Nothing to do here.
}
// Called when SKPaymentQueue has finished sending restored transactions.
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
{
UnityPurchasingLog(@"PaymentQueueRestoreCompletedTransactionsFinished");
[self UnitySendMessage: @"onTransactionsRestoredSuccess" payload: @""];
}
// Called if an error occurred while restoring transactions.
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error
{
UnityPurchasingLog(@"restoreCompletedTransactionsFailedWithError");
// Restore was cancelled or an error occurred, so notify user.
[self UnitySendMessage: @"onTransactionsRestoredFail" payload: error.localizedDescription];
}
// Called when an entitlement was revoked.
- (void)paymentQueue:(SKPaymentQueue *)queue didRevokeEntitlementsForProductIdentifiers:(NSArray *)productIdentifiers
{
UnityPurchasingLog(@"didRevokeEntitlementsForProductIdentifiers");
NSString* productIdsJSON = [UnityPurchasing serializeProductIdList: productIdentifiers];
[self UnitySendMessage: @"onEntitlementsRevoked" payload: productIdsJSON];
}
- (void)handleTransaction:(SKPaymentTransaction *)transaction
{
if (transaction.payment.productIdentifier == nil)
{
return;
}
SKProduct* product = [validProducts objectForKey: transaction.payment.productIdentifier];
switch (transaction.transactionState)
{
case SKPaymentTransactionStatePurchasing:
// Item is still in the process of being purchased
break;
case SKPaymentTransactionStatePurchased:
[self handleTransactionPurchased: transaction forProduct: product];
break;
case SKPaymentTransactionStateRestored:
[self handleTransactionRestored: transaction forProduct: product];
break;
case SKPaymentTransactionStateDeferred:
[self handleTransactionDeferred: transaction forProduct: product];
break;
case SKPaymentTransactionStateFailed:
[self handleTransactionFailed: transaction];
break;
}
}
- (void)handleTransactionPurchased:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product
{
#if MAC_APPSTORE
// There is no transactionReceipt on Mac
NSString* receipt = @"";
#else
// The transactionReceipt field is deprecated, but is being used here to validate Ask-To-Buy purchases
NSString* receipt = [transaction.transactionReceipt base64EncodedStringWithOptions: 0];
#endif
transactionReceipts[transaction.payment.productIdentifier] = receipt;
if (product != nil)
{
[self onTransactionSucceeded: transaction isRestored: false];
}
}
- (void)handleTransactionRestored:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product
{
if (product != nil)
{
[self onTransactionSucceeded: transaction isRestored: true];
}
}
- (void)handleTransactionDeferred:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product
{
if (product != nil)
{
UnityPurchasingLog(@"PurchaseDeferred");
[self UnitySendMessage: @"onProductPurchaseDeferred" payload: transaction.payment.productIdentifier];
}
}
- (void)handleTransactionFailed:(SKPaymentTransaction*)transaction
{
// Purchase was either cancelled by user or an error occurred.
NSString* errorCode = [NSString stringWithFormat: @"%ld", (long)transaction.error.code];
UnityPurchasingLog(@"PurchaseFailed: %@", errorCode);
NSString* reason = [self purchaseErrorCodeToReason: transaction.error.code];
NSString* errorCodeString = [UnityPurchasing storeKitErrorCodeNames][@(transaction.error.code)];
if (errorCodeString == nil)
{
errorCodeString = @"SKErrorUnknown";
}
NSString* errorDescription = [NSString stringWithFormat: @"APPLE_%@", transaction.error.localizedDescription];
[self onPurchaseFailed: transaction.payment.productIdentifier reason: reason errorCode: errorCodeString errorDescription: errorDescription];
// Finished transactions should be removed from the payment queue.
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
}
- (void)fetchStorePromotionOrder
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
if (@available(iOS 11.0, *))
{
[[SKProductStorePromotionController defaultController] fetchStorePromotionOrderWithCompletionHandler:^(NSArray<SKProduct *> * _Nonnull storePromotionOrder, NSError * _Nullable error) {
if (error)
{
UnityPurchasingLog(@"Error in fetchStorePromotionOrder: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);
[self UnitySendMessage: @"onFetchStorePromotionOrderFailed" payload: nil];
}
else
{
UnityPurchasingLog(@"Fetched %lu store-promotion ordered products", (unsigned long)[storePromotionOrder count]);
NSString *productIdsJSON = [UnityPurchasing serializeSKProductIdList: storePromotionOrder];
[self UnitySendMessage: @"onFetchStorePromotionOrderSucceeded" payload: productIdsJSON];
}
}];
}
else
#endif
{
UnityPurchasingLog(@"Fetch store promotion order is only available on iOS and tvOS 11 or later");
[self UnitySendMessage: @"onFetchStorePromotionOrderFailed" payload: nil];
}
}
- (void)updateStorePromotionOrder:(NSArray*)productIds
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
if (@available(iOS 11_0, *))
{
NSMutableArray* products = [[NSMutableArray alloc] init];
for (NSString* productId in productIds)
{
SKProduct* product = [validProducts objectForKey: productId];
if (product)
[products addObject: product];
}
SKProductStorePromotionController* controller = [SKProductStorePromotionController defaultController];
[controller updateStorePromotionOrder: products completionHandler:^(NSError* error) {
if (error)
UnityPurchasingLog(@"Error in updateStorePromotionOrder: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);
}];
}
else
#endif
{
UnityPurchasingLog(@"Update store promotion order is only available on iOS and tvOS 11 or later");
}
}
- (void)fetchStorePromotionVisibilityForProduct:(NSString*)productId
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
if (@available(iOS 11.0, macOS 11.0, tvOS 11.0, *))
{
SKProduct *product = [validProducts objectForKey: productId];
[[SKProductStorePromotionController defaultController]
fetchStorePromotionVisibilityForProduct: product completionHandler:^(SKProductStorePromotionVisibility storePromotionVisibility, NSError * _Nullable error) {
if (error)
{
UnityPurchasingLog(@"Error in fetchStorePromotionVisibilityForProduct: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);
[self UnitySendMessage: @"onFetchStorePromotionVisibilityFailed" payload: nil];
}
else
{
NSString *visibility = [UnityPurchasing getStringForStorePromotionVisibility: storePromotionVisibility];
UnityPurchasingLog(@"Fetched Store Promotion Visibility for %@", product.productIdentifier);
NSString *payload = [UnityPurchasing serializeVisibilityResultForProduct: productId withVisiblity: visibility];
[self UnitySendMessage: @"onFetchStorePromotionVisibilitySucceeded" payload: payload];
}
}
];
}
else
#endif
{
UnityPurchasingLog(@"Fetch store promotion visibility is only available on iOS, macOS and tvOS 11 or later");
[self UnitySendMessage: @"onFetchStorePromotionVisibilityFailed" payload: nil];
}
}
// visibility should be one of "Default", "Hide", or "Show"
- (void)updateStorePromotionVisibility:(NSString*)visibility forProduct:(NSString*)productId
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
if (@available(iOS 11_0, *))
{
SKProduct *product = [validProducts objectForKey: productId];
if (!product)
{
UnityPurchasingLog(@"updateStorePromotionVisibility unable to find product %@", productId);
return;
}
SKProductStorePromotionVisibility v = SKProductStorePromotionVisibilityDefault;
if ([visibility isEqualToString: @"Hide"])
v = SKProductStorePromotionVisibilityHide;
else if ([visibility isEqualToString: @"Show"])
v = SKProductStorePromotionVisibilityShow;
SKProductStorePromotionController* controller = [SKProductStorePromotionController defaultController];
[controller updateStorePromotionVisibility: v forProduct: product completionHandler:^(NSError* error) {
if (error)
UnityPurchasingLog(@"Error in updateStorePromotionVisibility: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);
}];
}
else
#endif
{
UnityPurchasingLog(@"Update store promotion visibility is only available on iOS and tvOS 11 or later");
}
}
- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
if (@available(iOS 11_0, *))
{
// Just defer to the early transaction observer. This should have no effect, just return whatever the observer returns.
return [[UnityEarlyTransactionObserver defaultObserver] paymentQueue: queue shouldAddStorePayment: payment forProduct: product];
}
#endif
return YES;
}
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
+ (NSString*)getStringForStorePromotionVisibility:(SKProductStorePromotionVisibility)storePromotionVisibility
API_AVAILABLE(macos(11.0), ios(11.0), tvos(11.0))
{
switch (storePromotionVisibility)
{
case SKProductStorePromotionVisibilityShow:
return @"Show";
case SKProductStorePromotionVisibilityHide:
return @"Hide";
case SKProductStorePromotionVisibilityDefault:
return @"Default";
default:
return @"Default";
}
}
#endif
+ (ProductDefinition*)decodeProductDefinition:(NSDictionary*)hash
{
ProductDefinition* product = [[ProductDefinition alloc] init];
product.id = [hash objectForKey: @"id"];
product.storeSpecificId = [hash objectForKey: @"storeSpecificId"];
product.type = [hash objectForKey: @"type"];
return product;
}
+ (NSArray*)deserializeProductDefs:(NSString*)json
{
NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding];
NSArray* hashes = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil];
NSMutableArray* result = [[NSMutableArray alloc] init];
for (NSDictionary* hash in hashes)
{
[result addObject: [self decodeProductDefinition: hash]];
}
return result;
}
+ (ProductDefinition*)deserializeProductDef:(NSString*)json
{
NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding];
NSDictionary* hash = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil];
return [self decodeProductDefinition: hash];
}
+ (NSString*)serializeProductMetadata:(NSArray*)appleProducts
{
NSMutableArray* hashes = [[NSMutableArray alloc] init];
for (id product in appleProducts)
{
if (NULL == [product productIdentifier])
{
UnityPurchasingLog(@"Product is missing an identifier!");
continue;
}
NSMutableDictionary* hash = [[NSMutableDictionary alloc] init];
[hashes addObject: hash];
[hash setObject: [product productIdentifier] forKey: @"storeSpecificId"];
NSMutableDictionary* metadata = [[NSMutableDictionary alloc] init];
[hash setObject: metadata forKey: @"metadata"];
if (NULL != [product price])
{
[metadata setObject: [product price] forKey: @"localizedPrice"];
}
if (NULL != [product priceLocale])
{
NSString *currencyCode = [[product priceLocale] objectForKey: NSLocaleCurrencyCode];
// NSLocaleCurrencyCode has been seen to return nil. Avoid crashing and report the issue to the log. E.g. https://developer.apple.com/forums/thread/119838
if (currencyCode != nil)
{
[metadata setObject: currencyCode forKey: @"isoCurrencyCode"];
}
else
{
UnityPurchasingLog(@"Error: unable to determine localized currency code for product {%@}, [SKProduct priceLocale] identifier {%@}. NSLocaleCurrencyCode {%@} is nil. Using ISO Unknown Currency code, instead: {%@}.", [product productIdentifier], [[product priceLocale] localeIdentifier], currencyCode, ISO_CURRENCY_CODE_UNKNOWN);
[metadata setObject: ISO_CURRENCY_CODE_UNKNOWN forKey: @"isoCurrencyCode"];
}
}
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 || __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
if (@available(iOS 14, macOS 11.0,*))
{
[product isFamilyShareable] ? [metadata setObject: @"true" forKey: @"isFamilyShareable"] : [metadata setObject: @"false" forKey: @"isFamilyShareable"];
}
#endif
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 || __TV_OS_VERSION_MAX_ALLOWED >= 110000 || __MAC_OS_X_VERSION_MAX_ALLOWED >= 101300
if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product introductoryPrice]))
{
[metadata setObject: [[product introductoryPrice] price] forKey: @"introductoryPrice"];
if (nil != [[product introductoryPrice] priceLocale])
{
NSString *currencyCode = [[[product introductoryPrice] priceLocale] objectForKey: NSLocaleCurrencyCode];
[metadata setObject: currencyCode forKey: @"introductoryPriceLocale"];
}
else
{
[metadata setObject: @"" forKey: @"introductoryPriceLocale"];
}
if (nil != [[product introductoryPrice] numberOfPeriods])
{
NSNumber *numberOfPeriods = [NSNumber numberWithInt: [[product introductoryPrice] numberOfPeriods]];
[metadata setObject: numberOfPeriods forKey: @"introductoryPriceNumberOfPeriods"];
}
else
{
[metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"];
}
if (nil != [[product introductoryPrice] subscriptionPeriod])
{
if (nil != [[[product introductoryPrice] subscriptionPeriod] numberOfUnits])
{
NSNumber *numberOfUnits = [NSNumber numberWithInt: [[[product introductoryPrice] subscriptionPeriod] numberOfUnits]];
[metadata setObject: numberOfUnits forKey: @"numberOfUnits"];
}
else
{
[metadata setObject: @"" forKey: @"numberOfUnits"];
}
if (nil != [[[product introductoryPrice] subscriptionPeriod] unit])
{
NSNumber *unit = [NSNumber numberWithInt: [[[product introductoryPrice] subscriptionPeriod] unit]];
[metadata setObject: unit forKey: @"unit"];
}
else
{
[metadata setObject: @"" forKey: @"unit"];
}
}
else
{
[metadata setObject: @"" forKey: @"numberOfUnits"];
[metadata setObject: @"" forKey: @"unit"];
}
}
else
{
[metadata setObject: @"" forKey: @"introductoryPrice"];
[metadata setObject: @"" forKey: @"introductoryPriceLocale"];
[metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"];
[metadata setObject: @"" forKey: @"numberOfUnits"];
[metadata setObject: @"" forKey: @"unit"];
}
if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product subscriptionPeriod]))
{
if (nil != [[product subscriptionPeriod] numberOfUnits])
{
NSNumber *numberOfUnits = [NSNumber numberWithInt: [[product subscriptionPeriod] numberOfUnits]];
[metadata setObject: numberOfUnits forKey: @"subscriptionNumberOfUnits"];
}
else
{
[metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"];
}
if (nil != [[product subscriptionPeriod] unit])
{
NSNumber *unit = [NSNumber numberWithInt: [[product subscriptionPeriod] unit]];
[metadata setObject: unit forKey: @"subscriptionPeriodUnit"];
}
else
{
[metadata setObject: @"" forKey: @"subscriptionPeriodUnit"];
}
}
else
{
[metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"];
[metadata setObject: @"" forKey: @"subscriptionPeriodUnit"];
}
#else
[metadata setObject: @"" forKey: @"introductoryPrice"];
[metadata setObject: @"" forKey: @"introductoryPriceLocale"];
[metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"];
[metadata setObject: @"" forKey: @"numberOfUnits"];
[metadata setObject: @"" forKey: @"unit"];
[metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"];
[metadata setObject: @"" forKey: @"subscriptionPeriodUnit"];
#endif
NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init];
[numberFormatter setFormatterBehavior: NSNumberFormatterBehavior10_4];
[numberFormatter setNumberStyle: NSNumberFormatterCurrencyStyle];
[numberFormatter setLocale: [product priceLocale]];
NSString *formattedString = [numberFormatter stringFromNumber: [product price]];
if (NULL == formattedString)
{
UnityPurchasingLog(@"Unable to format a localized price");
[metadata setObject: @"" forKey: @"localizedPriceString"];
}
else
{
[metadata setObject: formattedString forKey: @"localizedPriceString"];
}
if (NULL == [product localizedTitle])
{
UnityPurchasingLog(@"No localized title for: %@. Have your products been disapproved in itunes connect?", [product productIdentifier]);
[metadata setObject: @"" forKey: @"localizedTitle"];
}
else
{
[metadata setObject: [product localizedTitle] forKey: @"localizedTitle"];
}
if (NULL == [product localizedDescription])
{
UnityPurchasingLog(@"No localized description for: %@. Have your products been disapproved in itunes connect?", [product productIdentifier]);
[metadata setObject: @"" forKey: @"localizedDescription"];
}
else
{
[metadata setObject: [product localizedDescription] forKey: @"localizedDescription"];
}
}
NSData *data = [NSJSONSerialization dataWithJSONObject: hashes options: 0 error: nil];
return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];
}
+ (NSString*)serializeSKProductIdList:(NSArray<SKProduct *> *)products
{
NSMutableArray *productIds = [NSMutableArray arrayWithCapacity: products.count];
for (SKProduct *product in products)
{
[productIds addObject: product.productIdentifier];
}
return [UnityPurchasing serializeProductIdList: products];
}
+ (NSString*)serializeProductIdList:(NSArray*)products
{
NSData *data = [NSJSONSerialization dataWithJSONObject: products options: 0 error: nil];
return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];
}
+ (NSArray*)deserializeProductIdList:(NSString*)json
{
NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding];
NSDictionary* dict = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil];
return [[dict objectForKey: @"products"] copy];
}
+ (NSString*)serializeVisibilityResultForProduct:(NSString *)productId withVisiblity:(NSString *)visibility
{
NSDictionary *result = @{@"productId": productId, @"visibility": visibility};
NSData *data = [NSJSONSerialization dataWithJSONObject: result options: 0 error: nil];
return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];
}
// Note: this will need to be updated if Apple ever adds more StoreKit error codes.
+ (NSDictionary<NSNumber *, NSString *> *)storeKitErrorCodeNames
{
return @{
@(SKErrorUnknown): @"SKErrorUnknown",
@(SKErrorClientInvalid): @"SKErrorClientInvalid",
@(SKErrorPaymentCancelled): @"SKErrorPaymentCancelled",
@(SKErrorPaymentInvalid): @"SKErrorPaymentInvalid",
@(SKErrorPaymentNotAllowed): @"SKErrorPaymentNotAllowed",
#if !MAC_APPSTORE
@(SKErrorStoreProductNotAvailable): @"SKErrorStoreProductNotAvailable",
@(SKErrorCloudServicePermissionDenied): @"SKErrorCloudServicePermissionDenied",
@(SKErrorCloudServiceNetworkConnectionFailed): @"SKErrorCloudServiceNetworkConnectionFailed",
#endif
#if !MAC_APPSTORE && (__IPHONE_OS_VERSION_MAX_ALLOWED >= 103000 || __TV_OS_VERSION_MAX_ALLOWED >= 103000)
@(SKErrorCloudServiceRevoked): @"SKErrorCloudServiceRevoked",
#endif
};
}
#pragma mark - Internal Methods & Events
- (id)init
{
if (self = [super init])
{
validProducts = [[NSMutableDictionary alloc] init];
pendingTransactions = [[NSMutableDictionary alloc] init];
finishedTransactions = [[NSMutableSet alloc] init];
transactionReceipts = [[NSMutableDictionary alloc] init];
}
return self;
}
@end
UnityPurchasing* UnityPurchasing_instance = NULL;
UnityPurchasing* UnityPurchasing_getInstance()
{
if (NULL == UnityPurchasing_instance)
{
UnityPurchasing_instance = [[UnityPurchasing alloc] init];
}
return UnityPurchasing_instance;
}
// Make a heap allocated copy of a string.
// This is suitable for passing to managed code,
// which will free the string when it is garbage collected.
// Stack allocated variables must not be returned as results
// from managed to native calls.
char* UnityPurchasingMakeHeapAllocatedStringCopy(NSString* string)
{
if (NULL == string)
{
return NULL;
}
char* res = (char*)malloc([string length] + 1);
strcpy(res, [string UTF8String]);
return res;
}
void setUnityPurchasingCallback(UnityPurchasingCallback callback)
{
[UnityPurchasing_getInstance() setCallback: callback];
}
void unityPurchasingRetrieveProducts(const char* json)
{
NSString* str = [NSString stringWithUTF8String: json];
NSArray* productDefs = [UnityPurchasing deserializeProductDefs: str];
NSMutableSet* productIds = [[NSMutableSet alloc] init];
for (ProductDefinition* product in productDefs)
{
[productIds addObject: product.storeSpecificId];
}
[UnityPurchasing_getInstance() requestProducts: productIds];
}
void unityPurchasingPurchase(const char* json, const char* developerPayload)
{
NSString* str = [NSString stringWithUTF8String: json];
ProductDefinition* product = [UnityPurchasing deserializeProductDef: str];
[UnityPurchasing_getInstance() purchaseProduct: product];
}
void unityPurchasingFinishTransaction(const char* productJSON, const char* transactionId)
{
if (transactionId == NULL)
return;
Boolean hasProduct = productJSON != NULL;
NSString* tranId = [NSString stringWithUTF8String: transactionId];
[UnityPurchasing_getInstance() finishTransaction: tranId hasProduct: hasProduct];
}
void unityPurchasingRestoreTransactions()
{
UnityPurchasingLog(@"Restore transactions");
[UnityPurchasing_getInstance() restorePurchases];
}
void unityPurchasingAddTransactionObserver()
{
UnityPurchasingLog(@"Add transaction observer");
[UnityPurchasing_getInstance() addTransactionObserver];
}
void unityPurchasingRefreshAppReceipt()
{
UnityPurchasingLog(@"Refresh app receipt");
[UnityPurchasing_getInstance() refreshReceipt];
}
char* getUnityPurchasingAppReceipt()
{
@autoreleasepool {
NSString* receipt = [UnityPurchasing_getInstance() getAppReceipt];
return UnityPurchasingMakeHeapAllocatedStringCopy(receipt);
}
}
double getUnityPurchasingAppReceiptModificationDate()
{
NSDate* receiptModificationDate = [UnityPurchasing_getInstance() getAppReceiptModificationDate];
return receiptModificationDate.timeIntervalSince1970;
}
char* getUnityPurchasingTransactionReceiptForProductId(const char *productId)
{
NSString* receipt = [UnityPurchasing_getInstance() getTransactionReceiptForProductId: [NSString stringWithUTF8String: productId]];
return UnityPurchasingMakeHeapAllocatedStringCopy(receipt);
}
BOOL getUnityPurchasingCanMakePayments()
{
return [SKPaymentQueue canMakePayments];
}
void setSimulateAskToBuy(BOOL enabled)
{
UnityPurchasingLog(@"Set simulate Ask To Buy %@", enabled ? @"true" : @"false");
UnityPurchasing_getInstance().simulateAskToBuyEnabled = enabled;
}
BOOL getSimulateAskToBuy()
{
return UnityPurchasing_getInstance().simulateAskToBuyEnabled;
}
void unityPurchasingSetApplicationUsername(const char *username)
{
if (username == NULL)
return;
UnityPurchasing_getInstance().applicationUsername = [NSString stringWithUTF8String: username];
}
void unityPurchasingFetchStorePromotionOrder(void)
{
[UnityPurchasing_getInstance() fetchStorePromotionOrder];
}
void unityPurchasingFetchStorePromotionVisibility(const char *productId)
{
NSString* prodId = [NSString stringWithUTF8String: productId];
[UnityPurchasing_getInstance() fetchStorePromotionVisibilityForProduct: prodId];
}
// Expects json in this format:
// { "products": ["storeSpecificId1", "storeSpecificId2"] }
void unityPurchasingUpdateStorePromotionOrder(const char *json)
{
NSString* str = [NSString stringWithUTF8String: json];
NSArray* productIds = [UnityPurchasing deserializeProductIdList: str];
[UnityPurchasing_getInstance() updateStorePromotionOrder: productIds];
}
void unityPurchasingUpdateStorePromotionVisibility(const char *productId, const char *visibility)
{
NSString* prodId = [NSString stringWithUTF8String: productId];
NSString* visibilityStr = [NSString stringWithUTF8String: visibility];
[UnityPurchasing_getInstance() updateStorePromotionVisibility: visibilityStr forProduct: prodId];
}
void unityPurchasingInterceptPromotionalPurchases()
{
UnityPurchasingLog(@"Intercept promotional purchases");
UnityPurchasing_getInstance().interceptPromotionalPurchases = YES;
}
void unityPurchasingContinuePromotionalPurchases()
{
UnityPurchasingLog(@"Continue promotional purchases");
[UnityPurchasing_getInstance() initiateQueuedEarlyTransactionObserverPayments];
}
void unityPurchasingPresentCodeRedemptionSheet()
{
UnityPurchasingLog(@"Present code redemption sheet");
[UnityPurchasing_getInstance() presentCodeRedemptionSheet];
}