mirror of
https://github.com/copyrighttxt/watrbx-game-engine.git
synced 2026-09-05 05:07:48 +00:00
801 lines
34 KiB
Plaintext
801 lines
34 KiB
Plaintext
//
|
|
// StoreManager.m
|
|
// newapp
|
|
//
|
|
// Created by Dongwei Liao on 9/5/12.
|
|
// Copyright (c) 2012 roblox. All rights reserved.
|
|
//
|
|
|
|
#import <Foundation/NSJSONSerialization.h>
|
|
#import "AppDelegate.h"
|
|
#import "StoreManager.h"
|
|
#import "RobloxAlert.h"
|
|
#import "UserInfo.h"
|
|
#import "RobloxInfo.h"
|
|
#import "RobloxGoogleAnalytics.h"
|
|
#import "iOSSettingsService.h"
|
|
#import "Reachability.h"
|
|
#import "RobloxWebUtility.h"
|
|
#import "RobloxNotifications.h"
|
|
#import "PlaceLauncher.h"
|
|
|
|
#include "v8datamodel/DataModel.h"
|
|
#include "v8datamodel/MarketplaceService.h"
|
|
#include "Network/Players.h"
|
|
|
|
#define NUM_RETRIES_BEFORE_INFORM_USER 10
|
|
#define TOTAL_NUM_RETRIES 200
|
|
|
|
@implementation StoreManager
|
|
|
|
|
|
- (id) init
|
|
{
|
|
if(self = [super init])
|
|
{
|
|
// set these to some default value (should get them from app settings pretty quick though)
|
|
timeIntervalBetweenBCPurchaseInMinutes = 5;
|
|
timeIntervalBetweenCatalogPurchaseInMinutes = 5;
|
|
timeLimitForRetryOfBillingServiceBeforeGivingUp = 20;
|
|
allowAppleInAppPurchase = true;
|
|
readInAppPurchaseSettingsBeforeEveryPurchase = false;
|
|
|
|
#ifdef NATIVE_IOS
|
|
timeIntervalBetweenRobuxPurchaseInMinutes = 0;
|
|
#else
|
|
timeIntervalBetweenRobuxPurchaseInMinutes = 5;
|
|
#endif
|
|
|
|
|
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
|
|
iOSSettingsService * iosSettings = [[RobloxWebUtility sharedInstance] getCachediOSSettings];
|
|
#ifdef NATIVE_IOS
|
|
timeIntervalBetweenRobuxPurchaseInMinutes = iosSettings->GetValueRobloxHDTimeIntervalBetweenRobuxPurchaseInMinutes();
|
|
#else
|
|
timeIntervalBetweenRobuxPurchaseInMinutes = iosSettings->GetValueTimeIntervalBetweenRobuxPurchaseInMinutes();
|
|
#endif
|
|
|
|
timeIntervalBetweenBCPurchaseInMinutes = iosSettings->GetValueTimeIntervalBetweenBCPurchaseInMinutes();
|
|
timeIntervalBetweenCatalogPurchaseInMinutes = iosSettings->GetValueTimeIntervalBetweenCatalogPurchaseInMinutes();
|
|
timeLimitForRetryOfBillingServiceBeforeGivingUp = iosSettings->GetValueTimeLimitForBillingServiceRetriesBeforeGivingUp();
|
|
allowAppleInAppPurchase = iosSettings->GetValueAllowAppleInAppPurchase();
|
|
readInAppPurchaseSettingsBeforeEveryPurchase = iosSettings->GetValueReadInAppPurchaseSettingsBeforeEveryPurchase();
|
|
});
|
|
|
|
retries = 0;
|
|
|
|
[self resetPlayerPurchasing];
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(gotDidLeaveGameNotification:) name:RBX_NOTIFY_GAME_DID_LEAVE object:nil ];
|
|
|
|
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
// Initialize this after Login, do not initialize on Application Start up, the purchase should go to Roblox Authenticated User
|
|
+ (StoreManager*)getStoreMgr
|
|
{
|
|
static dispatch_once_t storePred = 0;
|
|
__strong static StoreManager* _sharedStoreManager = nil;
|
|
dispatch_once(&storePred, ^{ // Need to use GCD for thread-safe allocation of singleton
|
|
_sharedStoreManager = [[self alloc] init];
|
|
});
|
|
return _sharedStoreManager;
|
|
}
|
|
|
|
-(void) signalMarketplacePurchaseFinished:(bool) purchased
|
|
{
|
|
if (playerPurchasing.get() && !productIdPurchasing.empty())
|
|
{
|
|
if (RBX::MarketplaceService* marketService = RBX::ServiceProvider::find<RBX::MarketplaceService>(playerPurchasing.get()))
|
|
{
|
|
marketService->signalPromptNativePurchaseFinished(playerPurchasing, productIdPurchasing, purchased);
|
|
[self resetPlayerPurchasing];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (BOOL) canMakePurchase
|
|
{
|
|
[[UserInfo CurrentPlayer] UpdatePlayerInfo];
|
|
return [SKPaymentQueue canMakePayments];
|
|
}
|
|
|
|
-(void) resetPlayerPurchasing
|
|
{
|
|
playerPurchasing = shared_ptr<RBX::Instance>();
|
|
productIdPurchasing = "";
|
|
}
|
|
|
|
- (void) request:(SKRequest*)request didFailWithError:(NSError *)error
|
|
{
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_ERROR,"StoreManager: Could not contact App Store properly because %s", [[error localizedDescription] cStringUsingEncoding:NSUTF8StringEncoding]);
|
|
}
|
|
|
|
|
|
- (void) requestDidFinish:(SKRequest*)request
|
|
{
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_INFO,"StoreManager: Request finished");
|
|
}
|
|
|
|
// Restrict Time Bound Purchase only for Robux & BC Purchases, all catalog purchases should go through without restrictions.
|
|
-(BOOL) restrictTimeBoundPurchase:(NSString *)productId
|
|
{
|
|
if (readInAppPurchaseSettingsBeforeEveryPurchase)
|
|
{
|
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
|
|
iOSSettingsService * iosSettings = [[RobloxWebUtility sharedInstance] getCachediOSSettings];
|
|
#ifdef NATIVE_IOS
|
|
timeIntervalBetweenRobuxPurchaseInMinutes = iosSettings->GetValueRobloxHDTimeIntervalBetweenRobuxPurchaseInMinutes();
|
|
#else
|
|
timeIntervalBetweenRobuxPurchaseInMinutes = iosSettings->GetValueTimeIntervalBetweenRobuxPurchaseInMinutes();
|
|
#endif
|
|
|
|
timeIntervalBetweenBCPurchaseInMinutes = iosSettings->GetValueTimeIntervalBetweenBCPurchaseInMinutes();
|
|
timeIntervalBetweenCatalogPurchaseInMinutes = iosSettings->GetValueTimeIntervalBetweenCatalogPurchaseInMinutes();
|
|
timeLimitForRetryOfBillingServiceBeforeGivingUp = iosSettings->GetValueTimeLimitForBillingServiceRetriesBeforeGivingUp();
|
|
allowAppleInAppPurchase = iosSettings->GetValueAllowAppleInAppPurchase();
|
|
readInAppPurchaseSettingsBeforeEveryPurchase = iosSettings->GetValueReadInAppPurchaseSettingsBeforeEveryPurchase();
|
|
});
|
|
}
|
|
|
|
if(!allowAppleInAppPurchase)
|
|
return NO;
|
|
|
|
BOOL retVal = YES;
|
|
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
|
|
|
|
if([productId hasSuffix:@"Robux"])
|
|
{
|
|
NSTimeInterval lastPurchaseTimeRobux = [[NSUserDefaults standardUserDefaults] doubleForKey:@"LastPurchaseTimeRobux"];
|
|
// Block Robux Purchase for 10 minutes between purchases
|
|
if(lastPurchaseTimeRobux != 0 && currentTime < (lastPurchaseTimeRobux + 60 * timeIntervalBetweenRobuxPurchaseInMinutes))
|
|
retVal = NO;
|
|
}
|
|
else if([productId hasSuffix:@"monthBC"] || [productId hasSuffix:@"monthOBC"] || [productId hasSuffix:@"monthTBC"])
|
|
{
|
|
NSTimeInterval lastPurchaseTimeBC = [[NSUserDefaults standardUserDefaults] doubleForKey:@"LastPurchaseTimeBC"];
|
|
|
|
// Block BC Purchases for 24 hours between purchases
|
|
if(lastPurchaseTimeBC != 0 && currentTime < (lastPurchaseTimeBC + 60 * timeIntervalBetweenBCPurchaseInMinutes))
|
|
retVal = NO;
|
|
}
|
|
else // This is a Catalog purchase
|
|
{
|
|
NSTimeInterval lastPurchaseTimeCatalog = [[NSUserDefaults standardUserDefaults] doubleForKey:productId];
|
|
|
|
// Block Individual Catalog Purchases for 10 Minutes between purchases
|
|
if(lastPurchaseTimeCatalog != 0 && currentTime < (lastPurchaseTimeCatalog + 60 * timeIntervalBetweenCatalogPurchaseInMinutes))
|
|
retVal = NO;
|
|
}
|
|
|
|
return retVal;
|
|
}
|
|
|
|
-(void) reset
|
|
{
|
|
retries = 0;
|
|
[[NSUserDefaults standardUserDefaults] removeObjectForKey:@"PendingTransactionUserName"];
|
|
[[NSUserDefaults standardUserDefaults] removeObjectForKey:@"BillingServiceRetries"];
|
|
[[NSUserDefaults standardUserDefaults] removeObjectForKey:@"RecordedTimeForBillingServiceFirstFailure"];
|
|
[[NSUserDefaults standardUserDefaults] synchronize];
|
|
|
|
}
|
|
|
|
// Record the Purchase Time,s o that we can restrict Time Bound Purchase with ios Client Settings adjustments.
|
|
-(void) recordPurchaseTime:(NSString *)productId
|
|
{
|
|
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
|
|
|
|
if([productId hasSuffix:@"Robux"])
|
|
[[NSUserDefaults standardUserDefaults] setDouble:currentTime forKey:@"LastPurchaseTimeRobux"];
|
|
else if([productId hasSuffix:@"monthBC"] || [productId hasSuffix:@"monthOBC"] || [productId hasSuffix:@"monthTBC"])
|
|
[[NSUserDefaults standardUserDefaults] setDouble:currentTime forKey:@"LastPurchaseTimeBC"];
|
|
else // This is a Catalog Purchase
|
|
[[NSUserDefaults standardUserDefaults] setDouble:currentTime forKey:productId];
|
|
|
|
[[NSUserDefaults standardUserDefaults] synchronize];
|
|
|
|
}
|
|
|
|
|
|
|
|
-(void) productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
|
|
{
|
|
|
|
if ([self canMakePurchase])
|
|
{
|
|
NSInteger numProduct = [response.products count];
|
|
SKProduct* product_ = [response.products lastObject];
|
|
|
|
if (!product_)
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"Attempt" withLabel:@"NoProduct" withValue:0];
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureAppStore" withLabel:@"NoProduct" withValue:0];
|
|
NSString *message = [NSString stringWithFormat:@"Num products: %ld, no products found", (long)numProduct];
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_WARNING,"StoreManager: %s",[message cStringUsingEncoding:NSUTF8StringEncoding]);
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"CannotMakePurchase", nil)];
|
|
}
|
|
else
|
|
{
|
|
NSInteger numTransaction = [[[SKPaymentQueue defaultQueue] transactions] count];
|
|
|
|
if(numTransaction > 0)
|
|
{
|
|
SKPaymentTransaction *transaction = [[[SKPaymentQueue defaultQueue] transactions] objectAtIndex:0];
|
|
[self completeTransaction:transaction];
|
|
return;
|
|
}
|
|
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"Attempt" withLabel:product_.productIdentifier withValue:0];
|
|
NSString* pendingTransUserName = [UserInfo CurrentPlayer].username;
|
|
if([pendingTransUserName length] != 0)
|
|
{
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_INFO,"StoreManager: Product ID: %s", [product_.productIdentifier cStringUsingEncoding:NSUTF8StringEncoding]);
|
|
BOOL proceed = [self restrictTimeBoundPurchase:product_.productIdentifier];
|
|
|
|
if(proceed == YES)
|
|
{
|
|
SKPayment* payment = [SKPayment paymentWithProduct:product_];
|
|
[[SKPaymentQueue defaultQueue] addPayment:payment];
|
|
|
|
pendingTransUserName = [UserInfo CurrentPlayer].username;
|
|
[[NSUserDefaults standardUserDefaults] setObject:pendingTransUserName forKey:@"PendingTransactionUserName"];
|
|
[[NSUserDefaults standardUserDefaults] synchronize];
|
|
}
|
|
else
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
|
|
if([product_.productIdentifier hasSuffix:@"Robux"])
|
|
[RobloxAlert RobloxAlertWithMessage:[NSString stringWithFormat:NSLocalizedString(@"WaitForAnotherRobuxPurchase", nil)]];
|
|
else if([product_.productIdentifier hasSuffix:@"monthBC"] || [product_.productIdentifier hasSuffix:@"monthOBC"] || [product_.productIdentifier hasSuffix:@"monthTBC"])
|
|
[RobloxAlert RobloxAlertWithMessage:[NSString stringWithFormat:NSLocalizedString(@"WaitForAnotherBCPurchase", nil)]];
|
|
else // Catalog Purchase
|
|
[RobloxAlert RobloxAlertWithMessage:[NSString stringWithFormat:NSLocalizedString(@"WaitForAnotherCatalogPurchase", nil)]];
|
|
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureAppStore" withLabel:@"WaitForAnotherPurchaseTimeDelay" withValue:0];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
|
|
NSString *message = [NSString stringWithFormat:NSLocalizedString(@"SessionExpired", nil)];
|
|
[RobloxAlert RobloxAlertWithMessage:message];
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureAppStore" withLabel:product_.productIdentifier withValue:0];
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"CannotPurchaseBecauseParentalControl", nil)];
|
|
}
|
|
|
|
}
|
|
|
|
|
|
-(void) requestProductData:(NSString* )productId
|
|
{
|
|
NSSet* set = [NSSet setWithObject:productId];
|
|
SKProductsRequest* request = [[SKProductsRequest alloc] initWithProductIdentifiers:set];
|
|
request.delegate = self;
|
|
[request start];
|
|
}
|
|
|
|
|
|
-(void) purchaseProduct:(NSString*)productId
|
|
{
|
|
if (![self canMakePurchase])
|
|
{
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"CannotPurchaseBecauseParentalControl", nil)];
|
|
}
|
|
else
|
|
{
|
|
[self requestProductData:productId];
|
|
}
|
|
}
|
|
|
|
enum
|
|
{
|
|
Transaction_PendingMatchesCurrentUser = 0,
|
|
Transaction_PendingNotMatchesCurrentUser,
|
|
Transaction_PendingUsernameLost,
|
|
};
|
|
|
|
-(int) verifyIfCorrectUser
|
|
{
|
|
if (![[NSUserDefaults standardUserDefaults] objectForKey:@"PendingTransactionUserName"])
|
|
{
|
|
return Transaction_PendingUsernameLost;
|
|
}
|
|
else if ([[[NSUserDefaults standardUserDefaults] stringForKey:@"PendingTransactionUserName"] isEqualToString:@""])
|
|
{
|
|
return Transaction_PendingUsernameLost;
|
|
}
|
|
|
|
NSString *userNameOfPendingTransaction = [[NSUserDefaults standardUserDefaults] stringForKey:@"PendingTransactionUserName"];
|
|
|
|
UserInfo* user = [UserInfo CurrentPlayer];
|
|
NSString *userNameOfCurrentLoggedInUser = user.username;
|
|
|
|
if(![user userLoggedIn])// It means the user is logged out.
|
|
userNameOfCurrentLoggedInUser = @"";
|
|
|
|
if ([userNameOfCurrentLoggedInUser isEqualToString:userNameOfPendingTransaction])
|
|
return Transaction_PendingMatchesCurrentUser;
|
|
|
|
return Transaction_PendingNotMatchesCurrentUser;
|
|
|
|
}
|
|
|
|
// This is called whenevr we have unfinished transaction in the Apple Store Queue
|
|
// Also called when we do retries in case of Roblox Billing Service failure.
|
|
-(void) completeTransaction:(SKPaymentTransaction*)transaction
|
|
{
|
|
if (transaction == nil)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Reachability* reachability = [Reachability reachabilityForInternetConnection];
|
|
NetworkStatus remoteStatus = [reachability currentReachabilityStatus];
|
|
|
|
#pragma clang diagnostic push
|
|
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
|
NSData* receipt = transaction.transactionReceipt;
|
|
#pragma clang diagnostic pop
|
|
|
|
if (receipt == nil) // Apple Bug: iOS sometimes will give you the nil receipt even though Queue is empty, exit gracefully
|
|
return;
|
|
|
|
if (remoteStatus == NotReachable)
|
|
{
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"ConnectionError", nil)];
|
|
[self performSelector:@selector(completeTransaction:) withObject:transaction afterDelay:60*10];
|
|
return;
|
|
}
|
|
|
|
|
|
int transactionUserState = [self verifyIfCorrectUser];
|
|
if (transactionUserState == Transaction_PendingMatchesCurrentUser)
|
|
{
|
|
[self verifyReceipt:receipt forProductId:transaction.payment.productIdentifier paymentTransaction:transaction paymentQueue:[SKPaymentQueue defaultQueue]];
|
|
}
|
|
else if (transactionUserState == Transaction_PendingNotMatchesCurrentUser)
|
|
{
|
|
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
|
|
NSTimeInterval timeofFirstFailure = [[NSUserDefaults standardUserDefaults] doubleForKey:@"RecordedTimeForBillingServiceFirstFailure"];
|
|
|
|
// If Time elapsed is more than than the retries limit set with billing service, grant the current user the item.
|
|
if(timeofFirstFailure != 0 && currentTime > (timeofFirstFailure + 60 * 60 * timeLimitForRetryOfBillingServiceBeforeGivingUp))
|
|
{
|
|
[self verifyReceipt:receipt forProductId:transaction.payment.productIdentifier paymentTransaction:transaction paymentQueue:[SKPaymentQueue defaultQueue]];
|
|
}
|
|
else
|
|
{
|
|
[self performSelector:@selector(completeTransaction:) withObject:transaction afterDelay:60*10];
|
|
}
|
|
}
|
|
else if (transactionUserState == Transaction_PendingUsernameLost)
|
|
{
|
|
if ([[UserInfo CurrentPlayer] userLoggedIn])
|
|
{
|
|
[self verifyReceipt:receipt forProductId:transaction.payment.productIdentifier paymentTransaction:transaction paymentQueue:[SKPaymentQueue defaultQueue]];
|
|
}
|
|
else
|
|
{
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"AnyUserNeedsToSignIn", nil)];
|
|
[self performSelector:@selector(completeTransaction:) withObject:transaction afterDelay:60*10];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Transaction probably verified with Roblox Billing Service
|
|
// Finish the Transaction from Apple Store Kit if successful verification
|
|
// Otherwise keep retrying with Roblox billing service calls
|
|
-(void) endTransaction: (ReceiptStatus) verified paymentTransaction:(SKPaymentTransaction*) transaction paymentQueue:(SKPaymentQueue*) paymentQueue
|
|
{
|
|
if (verified == ReceiptStatus::ok)
|
|
{
|
|
[paymentQueue finishTransaction: transaction];
|
|
[self reset];
|
|
[self signalMarketplacePurchaseFinished:true];
|
|
|
|
// Record the purchase time of the successful transaction
|
|
[self recordPurchaseTime:transaction.payment.productIdentifier];
|
|
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"Success" withLabel:transaction.payment.productIdentifier withValue:0];
|
|
|
|
if (retries)
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"SuccessBillingServiceAfterRetry" withLabel:transaction.payment.productIdentifier withValue:retries];
|
|
else
|
|
{
|
|
NSInteger retriesRecorded = [[NSUserDefaults standardUserDefaults] integerForKey:@"BillingServiceRetries"];
|
|
if (retriesRecorded)
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"SuccessBillingServiceAfterRelaunch" withLabel:transaction.payment.productIdentifier withValue:1];
|
|
}
|
|
}
|
|
else if(verified == ReceiptStatus::bogus)
|
|
{
|
|
[paymentQueue finishTransaction: transaction];
|
|
[self reset];
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"Bogus" withLabel:transaction.payment.productIdentifier withValue:0];
|
|
}
|
|
else
|
|
{
|
|
retries++;
|
|
if(retries == 1)
|
|
{
|
|
|
|
NSInteger retriesRecorded = [[NSUserDefaults standardUserDefaults] integerForKey:@"BillingServiceRetries"];
|
|
if (retriesRecorded)
|
|
{
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureBillingServiceAfterRelaunch" withLabel:transaction.payment.productIdentifier withValue:1];
|
|
}
|
|
else
|
|
{
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureBillingService1stRetry" withLabel:transaction.payment.productIdentifier withValue:1];
|
|
|
|
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
|
|
[[NSUserDefaults standardUserDefaults] setDouble:currentTime forKey:@"RecordedTimeForBillingServiceFirstFailure"];
|
|
}
|
|
}
|
|
else
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureBillingServiceMultipleRetry" withLabel:transaction.payment.productIdentifier withValue:retries];
|
|
|
|
[[NSUserDefaults standardUserDefaults] setInteger:retries forKey:@"BillingServiceRetries"];
|
|
|
|
// Retry reached 10 attempts, inform user that his transaction is not working, and keep trying the transaction.
|
|
if (retries == NUM_RETRIES_BEFORE_INFORM_USER)
|
|
{
|
|
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"TransactionDelayedBody", nil)];
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureBillingServiceTooManyRetries" withLabel:transaction.payment.productIdentifier withValue:1];
|
|
|
|
});
|
|
}
|
|
|
|
|
|
// We will keep retrying for 200 times with increamenting the interval by 10 seconds on each failure
|
|
// Will give up retrying in 48 hours as recorded with 1st retry
|
|
if(retries <= TOTAL_NUM_RETRIES)
|
|
{
|
|
|
|
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
|
|
NSTimeInterval timeofFirstFailure = [[NSUserDefaults standardUserDefaults] doubleForKey:@"RecordedTimeForBillingServiceFirstFailure"];
|
|
|
|
// Try to make the billing service attempt for 48 hours, can be changed with client settings
|
|
if(timeofFirstFailure != 0 && currentTime < (timeofFirstFailure + 60 * 60 * timeLimitForRetryOfBillingServiceBeforeGivingUp))
|
|
{
|
|
|
|
#ifdef _DEBUG
|
|
// Do not keep retrying in DEBUG build, just clear out the transaction if it failed on the 1st attempt.
|
|
[paymentQueue finishTransaction: transaction];
|
|
[self reset];
|
|
return;
|
|
#endif
|
|
|
|
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
[self performSelector:@selector(completeTransaction:) withObject:transaction afterDelay:10*retries];
|
|
});
|
|
}
|
|
else// If this is a bogus receipt, we want to remove the transaction after 48 hours
|
|
{
|
|
|
|
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"TransactionFailedBody", nil)];
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureBillingServiceTooManyRetries" withLabel:transaction.payment.productIdentifier withValue:1];
|
|
|
|
});
|
|
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
[paymentQueue finishTransaction: transaction];
|
|
[self reset];
|
|
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureDueToBogusReceipt" withLabel:transaction.payment.productIdentifier withValue:0];
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// Transaction not yet initiated, it failed before being charged, it can be user clicked Cancel, or incorect password, etc
|
|
-(void) failedTransaction:(SKPaymentTransaction*)transaction
|
|
{
|
|
#ifndef NDEBUG
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_INFO,"StoreManager: User cancelled transaction: %s", [[transaction description] cStringUsingEncoding:NSUTF8StringEncoding]);
|
|
#endif
|
|
|
|
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
|
|
[self reset];
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
|
|
if (transaction.transactionState == SKPaymentTransactionStateFailed)
|
|
{
|
|
NSError *err = transaction.error;
|
|
NSString *desc = [err description];
|
|
NSLog(desc, nil);
|
|
|
|
if ([err code] == 2)
|
|
[RobloxGoogleAnalytics setEventTracking:@"Purchase" withAction:@"FailureAppStoreDueToCancelOrUserAuthentication" withLabel:transaction.payment.productIdentifier withValue:0];
|
|
}
|
|
}
|
|
|
|
|
|
-(void) restoreTransaction:(SKPaymentTransaction*)transaction
|
|
{
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_INFO,"StoreManager: restore transaction ...");
|
|
|
|
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
|
|
}
|
|
|
|
|
|
// SKObserver
|
|
-(void) paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
|
|
{
|
|
for (SKPaymentTransaction *transaction in transactions)
|
|
{
|
|
switch (transaction.transactionState)
|
|
{
|
|
case SKPaymentTransactionStatePurchased:
|
|
[self completeTransaction:transaction];
|
|
break;
|
|
case SKPaymentTransactionStateFailed:
|
|
[self failedTransaction:transaction];
|
|
break;
|
|
case SKPaymentTransactionStateRestored:
|
|
[self restoreTransaction:transaction];
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
- (NSString *)encode:(const uint8_t *)input length:(NSInteger)length
|
|
{
|
|
static char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
|
|
|
NSMutableData *data = [NSMutableData dataWithLength:((length + 2) / 3) * 4];
|
|
uint8_t *output = (uint8_t *)data.mutableBytes;
|
|
|
|
for (NSInteger i = 0; i < length; i += 3) {
|
|
NSInteger value = 0;
|
|
for (NSInteger j = i; j < (i + 3); j++) {
|
|
value <<= 8;
|
|
|
|
if (j < length) {
|
|
value |= (0xFF & input[j]);
|
|
}
|
|
}
|
|
|
|
NSInteger index = (i / 3) * 4;
|
|
output[index + 0] = table[(value >> 18) & 0x3F];
|
|
output[index + 1] = table[(value >> 12) & 0x3F];
|
|
output[index + 2] = (i + 1) < length ? table[(value >> 6) & 0x3F] : '=';
|
|
output[index + 3] = (i + 2) < length ? table[(value >> 0) & 0x3F] : '=';
|
|
}
|
|
|
|
NSString* s = [[NSString alloc] initWithData:data encoding:NSASCIIStringEncoding];
|
|
NSLog(@"This is the current iTunes purchase receipt:\n%@\n", s);
|
|
return s;
|
|
}
|
|
|
|
// Verify the Apple Transaction receit with the Roblox Billing Service
|
|
-(void) verifyReceipt:(NSData*)receiptData forProductId:(NSString*)productId paymentTransaction:(SKPaymentTransaction*) transaction paymentQueue:(SKPaymentQueue*) paymentQueue
|
|
{
|
|
|
|
NSString* urlString = [RobloxInfo getWWWBaseUrl];
|
|
|
|
NSObject *testObject = [[NSUserDefaults standardUserDefaults] objectForKey:@"RecordedTimeForBillingServiceFirstFailure"];
|
|
if (testObject)
|
|
urlString = [urlString stringByAppendingString:@"mobileapi/apple-purchase?isRetry=true"];
|
|
else
|
|
urlString = [urlString stringByAppendingString:@"mobileapi/apple-purchase"];
|
|
|
|
urlString = [urlString stringByReplacingOccurrencesOfString:@"http:" withString:@"https:"];
|
|
|
|
|
|
NSURL *url = [NSURL URLWithString: urlString];
|
|
NSMutableURLRequest *theRequest = [NSMutableURLRequest requestWithURL:url
|
|
cachePolicy:NSURLRequestReloadIgnoringCacheData
|
|
timeoutInterval:60];
|
|
|
|
[RobloxInfo setDefaultHTTPHeadersForRequest:theRequest];
|
|
[theRequest setHTTPMethod:@"POST"];
|
|
[theRequest setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"];
|
|
|
|
NSString *receiptDataString = [self encode:(uint8_t *)receiptData.bytes length:receiptData.length];
|
|
receiptDataString = [receiptDataString stringByReplacingOccurrencesOfString:@"+" withString: @"%2B"];
|
|
NSString *postData = [NSString stringWithFormat:@"receipt=%@&productId=%@", receiptDataString, productId];
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_INFO,"StoreManager: Receipt Data String: %s", [receiptDataString cStringUsingEncoding:NSUTF8StringEncoding]);
|
|
|
|
NSString *length = [NSString stringWithFormat:@"%lu", (unsigned long)[postData length]];
|
|
[theRequest setValue:length forHTTPHeaderField:@"Content-Length"];
|
|
|
|
[theRequest setHTTPBody:[postData dataUsingEncoding:NSASCIIStringEncoding]];
|
|
|
|
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
|
|
[NSURLConnection sendAsynchronousRequest:theRequest queue:queue completionHandler:
|
|
^(NSURLResponse *response, NSData *receiptResponseData, NSError *error)
|
|
{
|
|
ReceiptStatus retVal = ReceiptStatus::error;
|
|
NSHTTPURLResponse* urlResponse = ( NSHTTPURLResponse*) response;
|
|
|
|
if(urlResponse == nil)
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_ERROR,"StoreManager: Login failed due to improper cast");
|
|
|
|
long responseStatusCode = [urlResponse statusCode];
|
|
|
|
NSString *responseString = [[NSString alloc] initWithData:receiptResponseData encoding:NSASCIIStringEncoding];
|
|
|
|
if(responseStatusCode == 200 && [responseString isEqualToString: @"OK"])
|
|
{
|
|
retVal = ReceiptStatus::ok;
|
|
// bring up home view or error alert in main synchronous thread (ui stuff must be loaded in main thread)
|
|
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
[RobloxAlert RobloxAlertWithMessage:NSLocalizedString(@"PurchaseSuccessful", nil)];
|
|
#ifdef NATIVE_IOS
|
|
// this is a bit of a hack for now.
|
|
//we don't know _exactly_ how long it will take for the server to respond, so we err on the side of caution with 10sec.
|
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(10 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:RBX_NOTIFY_ROBUX_UPDATED object:nil];
|
|
});
|
|
#endif
|
|
});
|
|
}
|
|
else if(responseStatusCode == 200 && [responseString isEqualToString: @"Bogus"])
|
|
retVal = ReceiptStatus::bogus;
|
|
|
|
|
|
[self endTransaction:retVal paymentTransaction:transaction paymentQueue:paymentQueue];
|
|
|
|
}];
|
|
}
|
|
|
|
-(void) purchasePrecheck:(NSString*)productId completion:(void(^)(BOOL success, NSString* error))handler
|
|
{
|
|
NSString* urlString = [NSString stringWithFormat:@"%@mobileapi/validate-mobile-purchase", [RobloxInfo getWWWBaseUrl]];
|
|
|
|
NSURL *url = [NSURL URLWithString: urlString];
|
|
NSMutableURLRequest *theRequest = [NSMutableURLRequest requestWithURL:url
|
|
cachePolicy:NSURLRequestReloadIgnoringCacheData
|
|
timeoutInterval:60];
|
|
|
|
[RobloxInfo setDefaultHTTPHeadersForRequest:theRequest];
|
|
[theRequest setHTTPMethod:@"POST"];
|
|
[theRequest setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"];
|
|
|
|
NSString *postData = [NSString stringWithFormat:@"productId=%@", productId];
|
|
NSString *length = [NSString stringWithFormat:@"%lu", (unsigned long)[postData length]];
|
|
[theRequest setValue:length forHTTPHeaderField:@"Content-Length"];
|
|
[theRequest setHTTPBody:[postData dataUsingEncoding:NSASCIIStringEncoding]];
|
|
|
|
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
|
|
[NSURLConnection sendAsynchronousRequest:theRequest queue:queue completionHandler:
|
|
^(NSURLResponse *response, NSData *responseData, NSError *error)
|
|
{
|
|
NSHTTPURLResponse* urlResponse = ( NSHTTPURLResponse*) response;
|
|
|
|
if(urlResponse == nil)
|
|
RBX::StandardOut::singleton()->printf(RBX::MESSAGE_ERROR,"StoreManager: Purchase precheck failed due to missing response.");
|
|
|
|
long responseStatusCode = [urlResponse statusCode];
|
|
NSString *responseString = [[NSString alloc] initWithData:responseData encoding:NSASCIIStringEncoding];
|
|
|
|
if(responseStatusCode == 200)
|
|
{
|
|
if ([responseString isEqualToString: @"OK"])
|
|
handler(YES, nil);
|
|
|
|
else if ([responseString isEqualToString: @"Error"])
|
|
handler(NO, NSLocalizedString(@"PurchasingInvalidPurchase", nil));
|
|
|
|
else if ([responseString isEqualToString:@"Retry"])
|
|
handler(NO, NSLocalizedString(@"PurchasingBillingDown", nil));
|
|
|
|
else if ([responseString isEqualToString:@"Limit"])
|
|
handler(NO, NSLocalizedString(@"PurchasingLimit", nil));
|
|
|
|
else
|
|
handler(NO, NSLocalizedString(@"PurchasingUnknownError", nil));
|
|
}
|
|
else
|
|
handler(NO, NSLocalizedString(@"PurchasingUnknownError", nil));
|
|
|
|
}];
|
|
}
|
|
-(BOOL) checkForInAppPurchases:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
|
|
{
|
|
// handle in-app purchases
|
|
if (navigationType == UIWebViewNavigationTypeFormSubmitted)
|
|
{
|
|
[request mainDocumentURL];
|
|
}
|
|
else
|
|
{
|
|
NSURL *url = request.URL;
|
|
NSString *urlLoadStr = url.absoluteString;
|
|
|
|
NSString* urlBuy = [[RobloxInfo getWWWBaseUrl] stringByAppendingString:@"mobile-app-upgrades/buy"];
|
|
|
|
if ([urlLoadStr hasPrefix:urlBuy])
|
|
{
|
|
NSArray* tokens = [urlLoadStr componentsSeparatedByCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"="]];
|
|
if (tokens.count == 2)
|
|
{
|
|
NSString* id = (NSString*)[tokens objectAtIndex:1];
|
|
[self purchasePrecheck:id completion:^(BOOL success, NSString *errorMessage) {
|
|
if (success)
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[[StoreManager getStoreMgr] purchaseProduct:id];
|
|
});
|
|
else
|
|
[RobloxAlert RobloxAlertWithMessage:errorMessage];
|
|
}];
|
|
|
|
}
|
|
return YES;
|
|
}
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
-(void) promptNativePurchase:(shared_ptr<RBX::Instance>) player productId:(std::string) productId
|
|
{
|
|
if (!player)
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
return;
|
|
}
|
|
if (productId.empty())
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
return;
|
|
}
|
|
if (productIdPurchasing.compare("") != 0)
|
|
{ [self signalMarketplacePurchaseFinished:false];
|
|
return;
|
|
}
|
|
if (playerPurchasing != shared_ptr<RBX::Instance>())
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
return;
|
|
}
|
|
|
|
if (RBX::Network::Player* thePlayer = RBX::Instance::fastDynamicCast<RBX::Network::Player>(player.get()))
|
|
{
|
|
RBX::Network::Player* localPlayer = RBX::Network::Players::findLocalPlayer(RBX::DataModel::get(player.get()));
|
|
if (localPlayer == thePlayer)
|
|
{
|
|
productIdPurchasing = productId;
|
|
playerPurchasing = player;
|
|
|
|
[[StoreManager getStoreMgr] purchaseProduct:[NSString stringWithUTF8String:productId.c_str()]];
|
|
}
|
|
else
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
[self signalMarketplacePurchaseFinished:false];
|
|
}
|
|
}
|
|
|
|
- (void) gotDidLeaveGameNotification:(NSNotification *)aNotification
|
|
{
|
|
[self resetPlayerPurchasing];
|
|
}
|
|
|
|
@end
|