// Copyright Epic Games, Inc. All Rights Reserved. #if UE_WITH_STORE_KIT #include "OnlineStoreIOS.h" #include "Internationalization/Internationalization.h" #include "Internationalization/Culture.h" #include "Internationalization/FastDecimalFormat.h" #include "OnlineSubsystem.h" #import #import #include "Misc/ConfigCacheIni.h" #include "StoreKit2-Swift.h" #include "StoreKitSwiftInterop.h" #include "IOSAppDelegate.h" @interface FSKProductsRequestHelper : SKProductsRequest { }; /** Delegate to fire when this product request completes with the store kit */ @property FOnQueryOnlineStoreOffersComplete OfferDelegate; @end @implementation FSKProductsRequestHelper @end /** * Proxy class that notifies updates from FSKProductsRequestHelper to FOnlineStoreIOS on the game * thread (SKRequest responses are received in worker threads) */ @interface FStoreKitStoreProxy : NSObject { FOnlineStoreIOS* _StoreReceiver; NSMutableSet* _Requests; }; @end @implementation FStoreKitStoreProxy //////////////////////////////////////////////////////////////////// /// SKProductsRequestDelegate implementation -(void)productsRequest: (SKProductsRequest *)Request didReceiveResponse: (SKProductsResponse *)Response { UE_LOG_ONLINE_STOREV2(Log, TEXT("FStoreKitStoreProxy::didReceiveResponse")); // Response for SKRequest is received in a working thread. Use a task to notify from game thread FSKProductsRequestHelper* Helper = (FSKProductsRequestHelper*)Request; [FIOSAsyncTask CreateTaskWithBlock : ^ bool(void) { if(_StoreReceiver) { _StoreReceiver->OnProductsRequestResponse(Response, Helper.OfferDelegate); } return true; }]; } - (void)request:(SKRequest *)Request didFailWithError:(NSError *)Error { UE_LOG_ONLINE_STOREV2(Log, TEXT("FStoreKitStoreProxy::didFailWithError")); // Response for SKRequest is received in a working thread. Use a task to notify from game thread FSKProductsRequestHelper* Helper = (FSKProductsRequestHelper*)Request; [FIOSAsyncTask CreateTaskWithBlock : ^ bool(void) { if(_StoreReceiver) { _StoreReceiver->OnProductsRequestResponse(nil, Helper.OfferDelegate); } return true; }];} -(void)requestDidFinish:(SKRequest*)Request { [_Requests removeObject:Request]; } //////////////////////////////////////////////////////////////////// /// FStoreKitStoreProxy methods - (id)initWithReceiver: (FOnlineStoreIOS*)StoreReceiver { _StoreReceiver = StoreReceiver; _Requests = [[NSMutableSet alloc] init]; return self; } -(void)dealloc { for(FSKProductsRequestHelper* Request in _Requests) { [Request cancel]; } [_Requests release]; [super dealloc]; } -(void)Shutdown { _StoreReceiver = nullptr; } -(void)requestProductData: (NSMutableSet*)ProductIDs WithDelegate : (const FOnQueryOnlineStoreOffersComplete&)Delegate { UE_LOG_ONLINE_STOREV2(Log, TEXT("FStoreKitStoreProxy::requestProductData")); FSKProductsRequestHelper* Request = [[[FSKProductsRequestHelper alloc] initWithProductIdentifiers:ProductIDs] autorelease]; Request.delegate = self; Request.OfferDelegate = Delegate; [_Requests addObject:Request]; [Request start]; } @end //////////////////////////////////////////////////////////////////// /// FOnlineStoreIOS implementation FOnlineStoreIOS::FOnlineStoreIOS(FOnlineSubsystemIOS* InSubsystem) : bIsQueryInFlight(false) , Subsystem(InSubsystem) { UE_LOG_ONLINE_STOREV2(Verbose, TEXT( "FOnlineStoreIOS::FOnlineStoreIOS" )); StoreKitProxy = [[[FStoreKitStoreProxy alloc] initWithReceiver:this] autorelease]; } FOnlineStoreIOS::~FOnlineStoreIOS() { [StoreKitProxy Shutdown]; } void FOnlineStoreIOS::QueryCategories(const FUniqueNetId& UserId, const FOnQueryOnlineStoreCategoriesComplete& Delegate) { Delegate.ExecuteIfBound(false, TEXT("No CatalogService")); } void FOnlineStoreIOS::GetCategories(TArray& OutCategories) const { OutCategories.Empty(); } void FOnlineStoreIOS::QueryOffersByFilter(const FUniqueNetId& UserId, const FOnlineStoreFilter& Filter, const FOnQueryOnlineStoreOffersComplete& Delegate) { Delegate.ExecuteIfBound(false, TArray(), TEXT("No CatalogService")); } bool FOnlineStoreIOS::OffersNotAllowedInLocale(const FString& InLocale) { UE_LOG_ONLINE_STOREV2(Log, TEXT("Locale: %s"), *InLocale); // get the data from the config file TArray BannedLocales; GConfig->GetArray(TEXT("OnlineSubsystemIOS.Store"), TEXT("BannedLocales"), BannedLocales, GEngineIni); if (BannedLocales.Num() == 0) { // no banned locales just let the offer proceed return false; } TArray LocaleData; InLocale.ParseIntoArray(LocaleData, TEXT("-")); FString Locale = LocaleData.Num() > 1 ? LocaleData[1] : LocaleData[0]; return BannedLocales.Contains(Locale); } void FOnlineStoreIOS::QueryOffersById(const FUniqueNetId& UserId, const TArray& OfferIds, const FOnQueryOnlineStoreOffersComplete& Delegate) { UE_LOG_ONLINE_STOREV2(Verbose, TEXT("FOnlineStoreIOS::QueryOffersById")); if (bIsQueryInFlight) { Delegate.ExecuteIfBound(false, OfferIds, TEXT("Request already in flight")); } else if (OfferIds.Num() == 0) { Delegate.ExecuteIfBound(false, OfferIds, TEXT("No offers to query for")); } else if (OffersNotAllowedInLocale(FPlatformMisc::GetDefaultLocale())) { TArray OfferedIds; Delegate.ExecuteIfBound(true, OfferedIds, TEXT("")); } else { // autoreleased NSSet to hold IDs NSMutableSet* ProductSet = [NSMutableSet setWithCapacity:OfferIds.Num()]; for (int32 OfferIdx = 0; OfferIdx < OfferIds.Num(); OfferIdx++) { NSString* ID = [NSString stringWithFString:OfferIds[OfferIdx]]; // convert to NSString for the set objects [ProductSet addObject:ID]; } [StoreKitProxy requestProductData:ProductSet WithDelegate:Delegate]; bIsQueryInFlight = true; } } /** * Convert an Apple SKProduct reference into a engine FOnlineStoreOffer * (Apple has only Title/Description converted to Title/(short)Description) * * @param Product information about an Apple offer from iTunesConnect * * @return FOnlineStoreOffer with proper parameters filled in */ TSharedPtr ConvertProductToStoreOffer(SKProduct* Product) { TSharedPtr NewProductInfo = MakeShared(); NewProductInfo->OfferId = [Product productIdentifier]; NewProductInfo->Title = FText::FromString([Product localizedTitle]); NewProductInfo->Description = FText::FromString([Product localizedDescription]); //NewProductInfo->LongDescription = FText::FromString([Product localizedDescription]); NewProductInfo->CurrencyCode = [Product.priceLocale objectForKey : NSLocaleCurrencyCode]; // Convert the backend stated price into its base units FInternationalization& I18N = FInternationalization::Get(); const FCulture& Culture = *I18N.GetCurrentCulture(); const FDecimalNumberFormattingRules& FormattingRules = Culture.GetCurrencyFormattingRules(NewProductInfo->CurrencyCode); const FNumberFormattingOptions& FormattingOptions = FormattingRules.CultureDefaultFormattingOptions; double Val = static_cast([Product.price doubleValue]) * static_cast(FMath::Pow(10.0f, FormattingOptions.MaximumFractionalDigits)); NewProductInfo->NumericPrice = FMath::TruncToInt(Val + 0.5); // iOS doesn't support these fields, set to min and max defaults NewProductInfo->ReleaseDate = FDateTime::MinValue(); NewProductInfo->ExpirationDate = FDateTime::MaxValue(); NewProductInfo->PriceText = FText::AsCurrencyBase(NewProductInfo->NumericPrice, NewProductInfo->CurrencyCode); return NewProductInfo; } void FOnlineStoreIOS::OnProductsRequestResponse(SKProductsResponse* Response, const FOnQueryOnlineStoreOffersComplete& CompletionDelegate) { if(bIsQueryInFlight) { TArray OfferIds; bool bWasSuccessful = (Response != nil); if(bWasSuccessful) { for (SKProduct* Product in Response.products) { FOnlineStoreOfferIOS NewProductOffer(Product, ConvertProductToStoreOffer(Product)); AddOffer(NewProductOffer); OfferIds.Add(NewProductOffer.Offer->OfferId); UE_LOG_ONLINE_STOREV2(Log, TEXT("Product Identifier: %s, Name: %s, Desc: %s, Long Desc: %s, Price: %s IntPrice: %d"), *NewProductOffer.Offer->OfferId, *NewProductOffer.Offer->Title.ToString(), *NewProductOffer.Offer->Description.ToString(), *NewProductOffer.Offer->LongDescription.ToString(), *NewProductOffer.Offer->PriceText.ToString(), NewProductOffer.Offer->NumericPrice); } for (NSString *invalidProduct in Response.invalidProductIdentifiers) { UE_LOG_ONLINE_STOREV2(Warning, TEXT("Problem in iTunes connect configuration for product: %s"), *FString(invalidProduct)); } } CompletionDelegate.ExecuteIfBound(bWasSuccessful, OfferIds, TEXT("")); bIsQueryInFlight = false; } } void FOnlineStoreIOS::AddOffer(const FOnlineStoreOfferIOS& NewOffer) { if (NewOffer.IsValid()) { FOnlineStoreOfferIOS* Existing = CachedOffers.Find(NewOffer.Offer->OfferId); if (Existing != nullptr) { *Existing = NewOffer; } else { CachedOffers.Add(NewOffer.Offer->OfferId, NewOffer); } } } void FOnlineStoreIOS::GetOffers(TArray& OutOffers) const { for (const auto& CachedEntry : CachedOffers) { const FOnlineStoreOfferIOS& CachedOffer = CachedEntry.Value; OutOffers.Add(CachedOffer.Offer.ToSharedRef()); } } TSharedPtr FOnlineStoreIOS::GetOffer(const FUniqueOfferId& OfferId) const { TSharedPtr Result; const FOnlineStoreOfferIOS* Existing = CachedOffers.Find(OfferId); if (Existing != nullptr) { Result = Existing->Offer; } return Result; } SKProduct* FOnlineStoreIOS::GetSKProductByOfferId(const FUniqueOfferId& OfferId) { const FOnlineStoreOfferIOS* Existing = CachedOffers.Find(OfferId); if (Existing != nullptr) { return Existing->Product; } return nil; } #if !UE_BUILD_SHIPPING class FStoreExec : public FSelfRegisteringExec { virtual bool Exec_Dev(class UWorld* InWorld, const TCHAR* Cmd, FOutputDevice& Ar) override { if (!FParse::Command(&Cmd, TEXT("ASC"))) { return false; } if (FParse::Command(&Cmd, TEXT("REFUND"))) { TCHAR TransIdStr[256]; if (FParse::Token(Cmd, TransIdStr, UE_ARRAY_COUNT(TransIdStr), true)) { uint64 TransactionId = FCString::Atoi64(TransIdStr); [StoreKit2 testRefund:TransactionId inScene:[IOSAppDelegate GetDelegate].window.windowScene]; } return true; } return false; } } GStoreExec; #endif #endif //UE_WITH_STORE_KIT