diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index d862125c1d29..8033aa88dd38 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -3,6 +3,9 @@ description: A Flutter plugin for in-app purchases. Exposes APIs for making in-a homepage: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase version: 0.5.2 +# TODO(mvanbeusekom): Remove when in_app_purchase_platform_interface is published +publish_to: 'none' + dependencies: flutter: sdk: flutter @@ -10,6 +13,10 @@ dependencies: meta: ^1.3.0 collection: ^1.15.0 + # TODO(mvanbeusekom): Replace with pub.dev version when in_app_purchase_platform_interface is published + in_app_purchase_platform_interface: + path: ../in_app_purchase_platform_interface + dev_dependencies: build_runner: ^1.11.1 json_serializable: ^4.0.0 diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md new file mode 100644 index 000000000000..d46c124b9011 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +* Initial open-source release. \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_ios/LICENSE b/packages/in_app_purchase/in_app_purchase_ios/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/in_app_purchase/in_app_purchase_ios/README.md b/packages/in_app_purchase/in_app_purchase_ios/README.md new file mode 100644 index 000000000000..025ed36b72a6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/README.md @@ -0,0 +1,34 @@ +# in_app_purchase_ios + +The iOS implementation of [`in_app_purchase`][1]. + +## Usage + +### Import the package + +This package has been endorsed, meaning that you only need to add `in_app_purchase` +as a dependency in your `pubspec.yaml`. It will be automatically included in your app +when you depend on `package:in_app_purchase`. + +This is what the above means to your `pubspec.yaml`: + +```yaml +... +dependencies: + ... + in_app_purchase: ^0.6.0 + ... +``` + +If you wish to use the iOS package only, you can add `in_app_purchase_ios` as a +dependency: + +```yaml +... +dependencies: + ... + in_app_purchase_ios: ^1.0.0 + ... +``` + +[1]: ../in_app_purchase/in_app_purchase \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_ios/analysis_options.yaml b/packages/in_app_purchase/in_app_purchase_ios/analysis_options.yaml new file mode 100644 index 000000000000..5aeb4e7c5e21 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../../analysis_options_legacy.yaml diff --git a/packages/in_app_purchase/in_app_purchase_ios/build.yaml b/packages/in_app_purchase/in_app_purchase_ios/build.yaml new file mode 100644 index 000000000000..e15cf14b85fd --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + json_serializable: + options: + any_map: true + create_to_json: true diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Assets/.gitkeep b/packages/in_app_purchase/in_app_purchase_ios/ios/Assets/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h new file mode 100644 index 000000000000..2d0187e88aed --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h @@ -0,0 +1,35 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FIAObjectTranslator : NSObject + ++ (NSDictionary *)getMapFromSKProduct:(SKProduct *)product; + ++ (NSDictionary *)getMapFromSKProductSubscriptionPeriod:(SKProductSubscriptionPeriod *)period + API_AVAILABLE(ios(11.2)); + ++ (NSDictionary *)getMapFromSKProductDiscount:(SKProductDiscount *)discount + API_AVAILABLE(ios(11.2)); + ++ (NSDictionary *)getMapFromSKProductsResponse:(SKProductsResponse *)productResponse; + ++ (NSDictionary *)getMapFromSKPayment:(SKPayment *)payment; + ++ (NSDictionary *)getMapFromNSLocale:(NSLocale *)locale; + ++ (SKMutablePayment *)getSKMutablePaymentFromMap:(NSDictionary *)map; + ++ (NSDictionary *)getMapFromSKPaymentTransaction:(SKPaymentTransaction *)transaction; + ++ (NSDictionary *)getMapFromNSError:(NSError *)error; + +@end +; + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m new file mode 100644 index 000000000000..5d6e0a244a96 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m @@ -0,0 +1,172 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FIAObjectTranslator.h" + +#pragma mark - SKProduct Coders + +@implementation FIAObjectTranslator + ++ (NSDictionary *)getMapFromSKProduct:(SKProduct *)product { + if (!product) { + return nil; + } + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"localizedDescription" : product.localizedDescription ?: [NSNull null], + @"localizedTitle" : product.localizedTitle ?: [NSNull null], + @"productIdentifier" : product.productIdentifier ?: [NSNull null], + @"price" : product.price.description ?: [NSNull null] + + }]; + // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this + // expanded to a map. Matching android to only get the currencySymbol for now. + // https://github.com/flutter/flutter/issues/26610 + [map setObject:[FIAObjectTranslator getMapFromNSLocale:product.priceLocale] ?: [NSNull null] + forKey:@"priceLocale"]; + if (@available(iOS 11.2, *)) { + [map setObject:[FIAObjectTranslator + getMapFromSKProductSubscriptionPeriod:product.subscriptionPeriod] + ?: [NSNull null] + forKey:@"subscriptionPeriod"]; + } + if (@available(iOS 11.2, *)) { + [map setObject:[FIAObjectTranslator getMapFromSKProductDiscount:product.introductoryPrice] + ?: [NSNull null] + forKey:@"introductoryPrice"]; + } + if (@available(iOS 12.0, *)) { + [map setObject:product.subscriptionGroupIdentifier ?: [NSNull null] + forKey:@"subscriptionGroupIdentifier"]; + } + return map; +} + ++ (NSDictionary *)getMapFromSKProductSubscriptionPeriod:(SKProductSubscriptionPeriod *)period { + if (!period) { + return nil; + } + return @{@"numberOfUnits" : @(period.numberOfUnits), @"unit" : @(period.unit)}; +} + ++ (NSDictionary *)getMapFromSKProductDiscount:(SKProductDiscount *)discount { + if (!discount) { + return nil; + } + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"price" : discount.price.description ?: [NSNull null], + @"numberOfPeriods" : @(discount.numberOfPeriods), + @"subscriptionPeriod" : + [FIAObjectTranslator getMapFromSKProductSubscriptionPeriod:discount.subscriptionPeriod] + ?: [NSNull null], + @"paymentMode" : @(discount.paymentMode) + }]; + + // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this + // expanded to a map. Matching android to only get the currencySymbol for now. + // https://github.com/flutter/flutter/issues/26610 + [map setObject:[FIAObjectTranslator getMapFromNSLocale:discount.priceLocale] ?: [NSNull null] + forKey:@"priceLocale"]; + return map; +} + ++ (NSDictionary *)getMapFromSKProductsResponse:(SKProductsResponse *)productResponse { + if (!productResponse) { + return nil; + } + NSMutableArray *productsMapArray = [NSMutableArray new]; + for (SKProduct *product in productResponse.products) { + [productsMapArray addObject:[FIAObjectTranslator getMapFromSKProduct:product]]; + } + return @{ + @"products" : productsMapArray, + @"invalidProductIdentifiers" : productResponse.invalidProductIdentifiers ?: @[] + }; +} + ++ (NSDictionary *)getMapFromSKPayment:(SKPayment *)payment { + if (!payment) { + return nil; + } + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"productIdentifier" : payment.productIdentifier ?: [NSNull null], + @"requestData" : payment.requestData ? [[NSString alloc] initWithData:payment.requestData + encoding:NSUTF8StringEncoding] + : [NSNull null], + @"quantity" : @(payment.quantity), + @"applicationUsername" : payment.applicationUsername ?: [NSNull null] + }]; + if (@available(iOS 8.3, *)) { + [map setObject:@(payment.simulatesAskToBuyInSandbox) forKey:@"simulatesAskToBuyInSandbox"]; + } + return map; +} + ++ (NSDictionary *)getMapFromNSLocale:(NSLocale *)locale { + if (!locale) { + return nil; + } + NSMutableDictionary *map = [[NSMutableDictionary alloc] init]; + [map setObject:[locale objectForKey:NSLocaleCurrencySymbol] ?: [NSNull null] + forKey:@"currencySymbol"]; + [map setObject:[locale objectForKey:NSLocaleCurrencyCode] ?: [NSNull null] + forKey:@"currencyCode"]; + return map; +} + ++ (SKMutablePayment *)getSKMutablePaymentFromMap:(NSDictionary *)map { + if (!map) { + return nil; + } + SKMutablePayment *payment = [[SKMutablePayment alloc] init]; + payment.productIdentifier = map[@"productIdentifier"]; + NSString *utf8String = map[@"requestData"]; + payment.requestData = [utf8String dataUsingEncoding:NSUTF8StringEncoding]; + payment.quantity = [map[@"quantity"] integerValue]; + payment.applicationUsername = map[@"applicationUsername"]; + if (@available(iOS 8.3, *)) { + payment.simulatesAskToBuyInSandbox = [map[@"simulatesAskToBuyInSandbox"] boolValue]; + } + return payment; +} + ++ (NSDictionary *)getMapFromSKPaymentTransaction:(SKPaymentTransaction *)transaction { + if (!transaction) { + return nil; + } + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"error" : [FIAObjectTranslator getMapFromNSError:transaction.error] ?: [NSNull null], + @"payment" : transaction.payment ? [FIAObjectTranslator getMapFromSKPayment:transaction.payment] + : [NSNull null], + @"originalTransaction" : transaction.originalTransaction + ? [FIAObjectTranslator getMapFromSKPaymentTransaction:transaction.originalTransaction] + : [NSNull null], + @"transactionTimeStamp" : transaction.transactionDate + ? @(transaction.transactionDate.timeIntervalSince1970) + : [NSNull null], + @"transactionIdentifier" : transaction.transactionIdentifier ?: [NSNull null], + @"transactionState" : @(transaction.transactionState) + }]; + + return map; +} + ++ (NSDictionary *)getMapFromNSError:(NSError *)error { + if (!error) { + return nil; + } + NSMutableDictionary *userInfo = [NSMutableDictionary new]; + for (NSErrorUserInfoKey key in error.userInfo) { + id value = error.userInfo[key]; + if ([value isKindOfClass:[NSError class]]) { + userInfo[key] = [FIAObjectTranslator getMapFromNSError:value]; + } else if ([value isKindOfClass:[NSURL class]]) { + userInfo[key] = [value absoluteString]; + } else { + userInfo[key] = value; + } + } + return @{@"code" : @(error.code), @"domain" : error.domain ?: @"", @"userInfo" : userInfo}; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.h new file mode 100644 index 000000000000..94020ff2348b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.h @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class FlutterError; + +@interface FIAPReceiptManager : NSObject + +- (nullable NSString *)retrieveReceiptWithError:(FlutterError *_Nullable *_Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m new file mode 100644 index 000000000000..526364020ad3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m @@ -0,0 +1,26 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FIAPReceiptManager.h" +#import + +@implementation FIAPReceiptManager + +- (NSString *)retrieveReceiptWithError:(FlutterError **)error { + NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; + NSData *receipt = [self getReceiptData:receiptURL]; + if (!receipt) { + *error = [FlutterError errorWithCode:@"storekit_no_receipt" + message:@"Cannot find receipt for the current main bundle." + details:nil]; + return nil; + } + return [receipt base64EncodedStringWithOptions:kNilOptions]; +} + +- (NSData *)getReceiptData:(NSURL *)url { + return [NSData dataWithContentsOfURL:url]; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPRequestHandler.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPRequestHandler.h new file mode 100644 index 000000000000..cbf21d6e161f --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPRequestHandler.h @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^ProductRequestCompletion)(SKProductsResponse *_Nullable response, + NSError *_Nullable errror); + +@interface FIAPRequestHandler : NSObject + +- (instancetype)initWithRequest:(SKRequest *)request; +- (void)startProductRequestWithCompletionHandler:(ProductRequestCompletion)completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPRequestHandler.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPRequestHandler.m new file mode 100644 index 000000000000..8767265d8544 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPRequestHandler.m @@ -0,0 +1,55 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FIAPRequestHandler.h" +#import + +#pragma mark - Main Handler + +@interface FIAPRequestHandler () + +@property(copy, nonatomic) ProductRequestCompletion completion; +@property(strong, nonatomic) SKRequest *request; + +@end + +@implementation FIAPRequestHandler + +- (instancetype)initWithRequest:(SKRequest *)request { + self = [super init]; + if (self) { + self.request = request; + request.delegate = self; + } + return self; +} + +- (void)startProductRequestWithCompletionHandler:(ProductRequestCompletion)completion { + self.completion = completion; + [self.request start]; +} + +- (void)productsRequest:(SKProductsRequest *)request + didReceiveResponse:(SKProductsResponse *)response { + if (self.completion) { + self.completion(response, nil); + // set the completion to nil here so self.completion won't be triggered again in + // requestDidFinish for SKProductRequest. + self.completion = nil; + } +} + +- (void)requestDidFinish:(SKRequest *)request { + if (self.completion) { + self.completion(nil, nil); + } +} + +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error { + if (self.completion) { + self.completion(nil, error); + } +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h new file mode 100644 index 000000000000..fddeb07e01a3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h @@ -0,0 +1,46 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +@class SKPaymentTransaction; + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^TransactionsUpdated)(NSArray *transactions); +typedef void (^TransactionsRemoved)(NSArray *transactions); +typedef void (^RestoreTransactionFailed)(NSError *error); +typedef void (^RestoreCompletedTransactionsFinished)(void); +typedef BOOL (^ShouldAddStorePayment)(SKPayment *payment, SKProduct *product); +typedef void (^UpdatedDownloads)(NSArray *downloads); + +@interface FIAPaymentQueueHandler : NSObject + +- (instancetype)initWithQueue:(nonnull SKPaymentQueue *)queue + transactionsUpdated:(nullable TransactionsUpdated)transactionsUpdated + transactionRemoved:(nullable TransactionsRemoved)transactionsRemoved + restoreTransactionFailed:(nullable RestoreTransactionFailed)restoreTransactionFailed + restoreCompletedTransactionsFinished: + (nullable RestoreCompletedTransactionsFinished)restoreCompletedTransactionsFinished + shouldAddStorePayment:(nullable ShouldAddStorePayment)shouldAddStorePayment + updatedDownloads:(nullable UpdatedDownloads)updatedDownloads; +// Can throw exceptions if the transaction type is purchasing, should always used in a @try block. +- (void)finishTransaction:(nonnull SKPaymentTransaction *)transaction; +- (void)restoreTransactions:(nullable NSString *)applicationName; +- (void)presentCodeRedemptionSheet; +- (NSArray *)getUnfinishedTransactions; + +// This method needs to be called before any other methods. +- (void)startObservingPaymentQueue; + +// Appends a payment to the SKPaymentQueue. +// +// @param payment Payment object to be added to the payment queue. +// @return whether "addPayment" was successful. +- (BOOL)addPayment:(SKPayment *)payment; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m new file mode 100644 index 000000000000..eb3348e4b3c9 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m @@ -0,0 +1,122 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FIAPaymentQueueHandler.h" + +@interface FIAPaymentQueueHandler () + +@property(strong, nonatomic) SKPaymentQueue *queue; +@property(nullable, copy, nonatomic) TransactionsUpdated transactionsUpdated; +@property(nullable, copy, nonatomic) TransactionsRemoved transactionsRemoved; +@property(nullable, copy, nonatomic) RestoreTransactionFailed restoreTransactionFailed; +@property(nullable, copy, nonatomic) + RestoreCompletedTransactionsFinished paymentQueueRestoreCompletedTransactionsFinished; +@property(nullable, copy, nonatomic) ShouldAddStorePayment shouldAddStorePayment; +@property(nullable, copy, nonatomic) UpdatedDownloads updatedDownloads; + +@end + +@implementation FIAPaymentQueueHandler + +- (instancetype)initWithQueue:(nonnull SKPaymentQueue *)queue + transactionsUpdated:(nullable TransactionsUpdated)transactionsUpdated + transactionRemoved:(nullable TransactionsRemoved)transactionsRemoved + restoreTransactionFailed:(nullable RestoreTransactionFailed)restoreTransactionFailed + restoreCompletedTransactionsFinished: + (nullable RestoreCompletedTransactionsFinished)restoreCompletedTransactionsFinished + shouldAddStorePayment:(nullable ShouldAddStorePayment)shouldAddStorePayment + updatedDownloads:(nullable UpdatedDownloads)updatedDownloads { + self = [super init]; + if (self) { + _queue = queue; + _transactionsUpdated = transactionsUpdated; + _transactionsRemoved = transactionsRemoved; + _restoreTransactionFailed = restoreTransactionFailed; + _paymentQueueRestoreCompletedTransactionsFinished = restoreCompletedTransactionsFinished; + _shouldAddStorePayment = shouldAddStorePayment; + _updatedDownloads = updatedDownloads; + } + return self; +} + +- (void)startObservingPaymentQueue { + [_queue addTransactionObserver:self]; +} + +- (BOOL)addPayment:(SKPayment *)payment { + for (SKPaymentTransaction *transaction in self.queue.transactions) { + if ([transaction.payment.productIdentifier isEqualToString:payment.productIdentifier]) { + return NO; + } + } + [self.queue addPayment:payment]; + return YES; +} + +- (void)finishTransaction:(SKPaymentTransaction *)transaction { + [self.queue finishTransaction:transaction]; +} + +- (void)restoreTransactions:(nullable NSString *)applicationName { + if (applicationName) { + [self.queue restoreCompletedTransactionsWithApplicationUsername:applicationName]; + } else { + [self.queue restoreCompletedTransactions]; + } +} + +- (void)presentCodeRedemptionSheet { + if (@available(iOS 14, *)) { + [self.queue presentCodeRedemptionSheet]; + } else { + NSLog(@"presentCodeRedemptionSheet is only available on iOS 14 or newer"); + } +} + +#pragma mark - observing + +// Sent when the transaction array has changed (additions or state changes). Client should check +// state of transactions and finish as appropriate. +- (void)paymentQueue:(SKPaymentQueue *)queue + updatedTransactions:(NSArray *)transactions { + // notify dart through callbacks. + self.transactionsUpdated(transactions); +} + +// Sent when transactions are removed from the queue (via finishTransaction:). +- (void)paymentQueue:(SKPaymentQueue *)queue + removedTransactions:(NSArray *)transactions { + self.transactionsRemoved(transactions); +} + +// Sent when an error is encountered while adding transactions from the user's purchase history back +// to the queue. +- (void)paymentQueue:(SKPaymentQueue *)queue + restoreCompletedTransactionsFailedWithError:(NSError *)error { + self.restoreTransactionFailed(error); +} + +// Sent when all transactions from the user's purchase history have successfully been added back to +// the queue. +- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue { + self.paymentQueueRestoreCompletedTransactionsFinished(); +} + +// Sent when the download state has changed. +- (void)paymentQueue:(SKPaymentQueue *)queue updatedDownloads:(NSArray *)downloads { + self.updatedDownloads(downloads); +} + +// Sent when a user initiates an IAP buy from the App Store +- (BOOL)paymentQueue:(SKPaymentQueue *)queue + shouldAddStorePayment:(SKPayment *)payment + forProduct:(SKProduct *)product { + return (self.shouldAddStorePayment(payment, product)); +} + +- (NSArray *)getUnfinishedTransactions { + return self.queue.transactions; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.h new file mode 100644 index 000000000000..8cb42f3fe8c2 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.h @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +@class FIAPaymentQueueHandler; +@class FIAPReceiptManager; + +@interface InAppPurchasePlugin : NSObject + +@property(strong, nonatomic) FIAPaymentQueueHandler *paymentQueueHandler; + +- (instancetype)initWithReceiptManager:(FIAPReceiptManager *)receiptManager + NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m new file mode 100644 index 000000000000..650cd812d470 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m @@ -0,0 +1,360 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "InAppPurchasePlugin.h" +#import +#import "FIAObjectTranslator.h" +#import "FIAPReceiptManager.h" +#import "FIAPRequestHandler.h" +#import "FIAPaymentQueueHandler.h" + +@interface InAppPurchasePlugin () + +// Holding strong references to FIAPRequestHandlers. Remove the handlers from the set after +// the request is finished. +@property(strong, nonatomic, readonly) NSMutableSet *requestHandlers; + +// After querying the product, the available products will be saved in the map to be used +// for purchase. +@property(strong, nonatomic, readonly) NSMutableDictionary *productsCache; + +// Call back channel to dart used for when a listener function is triggered. +@property(strong, nonatomic, readonly) FlutterMethodChannel *callbackChannel; +@property(strong, nonatomic, readonly) NSObject *registry; +@property(strong, nonatomic, readonly) NSObject *messenger; +@property(strong, nonatomic, readonly) NSObject *registrar; + +@property(strong, nonatomic, readonly) FIAPReceiptManager *receiptManager; + +@end + +@implementation InAppPurchasePlugin + ++ (void)registerWithRegistrar:(NSObject *)registrar { + FlutterMethodChannel *channel = + [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/in_app_purchase" + binaryMessenger:[registrar messenger]]; + InAppPurchasePlugin *instance = [[InAppPurchasePlugin alloc] initWithRegistrar:registrar]; + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (instancetype)initWithReceiptManager:(FIAPReceiptManager *)receiptManager { + self = [super init]; + _receiptManager = receiptManager; + _requestHandlers = [NSMutableSet new]; + _productsCache = [NSMutableDictionary new]; + return self; +} + +- (instancetype)initWithRegistrar:(NSObject *)registrar { + self = [self initWithReceiptManager:[FIAPReceiptManager new]]; + _registrar = registrar; + _registry = [registrar textures]; + _messenger = [registrar messenger]; + + __weak typeof(self) weakSelf = self; + _paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:[SKPaymentQueue defaultQueue] + transactionsUpdated:^(NSArray *_Nonnull transactions) { + [weakSelf handleTransactionsUpdated:transactions]; + } + transactionRemoved:^(NSArray *_Nonnull transactions) { + [weakSelf handleTransactionsRemoved:transactions]; + } + restoreTransactionFailed:^(NSError *_Nonnull error) { + [weakSelf handleTransactionRestoreFailed:error]; + } + restoreCompletedTransactionsFinished:^{ + [weakSelf restoreCompletedTransactionsFinished]; + } + shouldAddStorePayment:^BOOL(SKPayment *payment, SKProduct *product) { + return [weakSelf shouldAddStorePayment:payment product:product]; + } + updatedDownloads:^void(NSArray *_Nonnull downloads) { + [weakSelf updatedDownloads:downloads]; + }]; + [_paymentQueueHandler startObservingPaymentQueue]; + _callbackChannel = + [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/in_app_purchase" + binaryMessenger:[registrar messenger]]; + return self; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if ([@"-[SKPaymentQueue canMakePayments:]" isEqualToString:call.method]) { + [self canMakePayments:result]; + } else if ([@"-[SKPaymentQueue transactions]" isEqualToString:call.method]) { + [self getPendingTransactions:result]; + } else if ([@"-[InAppPurchasePlugin startProductRequest:result:]" isEqualToString:call.method]) { + [self handleProductRequestMethodCall:call result:result]; + } else if ([@"-[InAppPurchasePlugin addPayment:result:]" isEqualToString:call.method]) { + [self addPayment:call result:result]; + } else if ([@"-[InAppPurchasePlugin finishTransaction:result:]" isEqualToString:call.method]) { + [self finishTransaction:call result:result]; + } else if ([@"-[InAppPurchasePlugin restoreTransactions:result:]" isEqualToString:call.method]) { + [self restoreTransactions:call result:result]; + } else if ([@"-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]" + isEqualToString:call.method]) { + [self presentCodeRedemptionSheet:call result:result]; + } else if ([@"-[InAppPurchasePlugin retrieveReceiptData:result:]" isEqualToString:call.method]) { + [self retrieveReceiptData:call result:result]; + } else if ([@"-[InAppPurchasePlugin refreshReceipt:result:]" isEqualToString:call.method]) { + [self refreshReceipt:call result:result]; + } else { + result(FlutterMethodNotImplemented); + } +} + +- (void)canMakePayments:(FlutterResult)result { + result([NSNumber numberWithBool:[SKPaymentQueue canMakePayments]]); +} + +- (void)getPendingTransactions:(FlutterResult)result { + NSArray *transactions = + [self.paymentQueueHandler getUnfinishedTransactions]; + NSMutableArray *transactionMaps = [[NSMutableArray alloc] init]; + for (SKPaymentTransaction *transaction in transactions) { + [transactionMaps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:transaction]]; + } + result(transactionMaps); +} + +- (void)handleProductRequestMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if (![call.arguments isKindOfClass:[NSArray class]]) { + result([FlutterError errorWithCode:@"storekit_invalid_argument" + message:@"Argument type of startRequest is not array" + details:call.arguments]); + return; + } + NSArray *productIdentifiers = (NSArray *)call.arguments; + SKProductsRequest *request = + [self getProductRequestWithIdentifiers:[NSSet setWithArray:productIdentifiers]]; + FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + [self.requestHandlers addObject:handler]; + __weak typeof(self) weakSelf = self; + [handler startProductRequestWithCompletionHandler:^(SKProductsResponse *_Nullable response, + NSError *_Nullable error) { + if (error) { + result([FlutterError errorWithCode:@"storekit_getproductrequest_platform_error" + message:error.localizedDescription + details:error.description]); + return; + } + if (!response) { + result([FlutterError errorWithCode:@"storekit_platform_no_response" + message:@"Failed to get SKProductResponse in startRequest " + @"call. Error occured on iOS platform" + details:call.arguments]); + return; + } + for (SKProduct *product in response.products) { + [self.productsCache setObject:product forKey:product.productIdentifier]; + } + result([FIAObjectTranslator getMapFromSKProductsResponse:response]); + [weakSelf.requestHandlers removeObject:handler]; + }]; +} + +- (void)addPayment:(FlutterMethodCall *)call result:(FlutterResult)result { + if (![call.arguments isKindOfClass:[NSDictionary class]]) { + result([FlutterError errorWithCode:@"storekit_invalid_argument" + message:@"Argument type of addPayment is not a Dictionary" + details:call.arguments]); + return; + } + NSDictionary *paymentMap = (NSDictionary *)call.arguments; + NSString *productID = [paymentMap objectForKey:@"productIdentifier"]; + // When a product is already fetched, we create a payment object with + // the product to process the payment. + SKProduct *product = [self getProduct:productID]; + if (!product) { + result([FlutterError + errorWithCode:@"storekit_invalid_payment_object" + message: + @"You have requested a payment for an invalid product. Either the " + @"`productIdentifier` of the payment is not valid or the product has not been " + @"fetched before adding the payment to the payment queue." + details:call.arguments]); + return; + } + SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product]; + payment.applicationUsername = [paymentMap objectForKey:@"applicationUsername"]; + NSNumber *quantity = [paymentMap objectForKey:@"quantity"]; + payment.quantity = (quantity != nil) ? quantity.integerValue : 1; + if (@available(iOS 8.3, *)) { + NSNumber *simulatesAskToBuyInSandbox = [paymentMap objectForKey:@"simulatesAskToBuyInSandbox"]; + payment.simulatesAskToBuyInSandbox = (id)simulatesAskToBuyInSandbox == (id)[NSNull null] + ? NO + : [simulatesAskToBuyInSandbox boolValue]; + } + + if (![self.paymentQueueHandler addPayment:payment]) { + result([FlutterError + errorWithCode:@"storekit_duplicate_product_object" + message:@"There is a pending transaction for the same product identifier. Please " + @"either wait for it to be finished or finish it manually using " + @"`completePurchase` to avoid edge cases." + + details:call.arguments]); + return; + } + result(nil); +} + +- (void)finishTransaction:(FlutterMethodCall *)call result:(FlutterResult)result { + if (![call.arguments isKindOfClass:[NSDictionary class]]) { + result([FlutterError errorWithCode:@"storekit_invalid_argument" + message:@"Argument type of finishTransaction is not a Dictionary" + details:call.arguments]); + return; + } + NSDictionary *paymentMap = (NSDictionary *)call.arguments; + NSString *transactionIdentifier = [paymentMap objectForKey:@"transactionIdentifier"]; + NSString *productIdentifier = [paymentMap objectForKey:@"productIdentifier"]; + + NSArray *pendingTransactions = + [self.paymentQueueHandler getUnfinishedTransactions]; + + for (SKPaymentTransaction *transaction in pendingTransactions) { + // If the user cancels the purchase dialog we won't have a transactionIdentifier. + // So if it is null AND a transaction in the pendingTransactions list has + // also a null transactionIdentifier we check for equal product identifiers. + if ([transaction.transactionIdentifier isEqualToString:transactionIdentifier] || + ([transactionIdentifier isEqual:[NSNull null]] && + transaction.transactionIdentifier == nil && + [transaction.payment.productIdentifier isEqualToString:productIdentifier])) { + @try { + [self.paymentQueueHandler finishTransaction:transaction]; + } @catch (NSException *e) { + result([FlutterError errorWithCode:@"storekit_finish_transaction_exception" + message:e.name + details:e.description]); + return; + } + } + } + + result(nil); +} + +- (void)restoreTransactions:(FlutterMethodCall *)call result:(FlutterResult)result { + if (call.arguments && ![call.arguments isKindOfClass:[NSString class]]) { + result([FlutterError + errorWithCode:@"storekit_invalid_argument" + message:@"Argument is not nil and the type of finishTransaction is not a string." + details:call.arguments]); + return; + } + [self.paymentQueueHandler restoreTransactions:call.arguments]; + result(nil); +} + +- (void)presentCodeRedemptionSheet:(FlutterMethodCall *)call result:(FlutterResult)result { + [self.paymentQueueHandler presentCodeRedemptionSheet]; + result(nil); +} + +- (void)retrieveReceiptData:(FlutterMethodCall *)call result:(FlutterResult)result { + FlutterError *error = nil; + NSString *receiptData = [self.receiptManager retrieveReceiptWithError:&error]; + if (error) { + result(error); + return; + } + result(receiptData); +} + +- (void)refreshReceipt:(FlutterMethodCall *)call result:(FlutterResult)result { + NSDictionary *arguments = call.arguments; + SKReceiptRefreshRequest *request; + if (arguments) { + if (![arguments isKindOfClass:[NSDictionary class]]) { + result([FlutterError errorWithCode:@"storekit_invalid_argument" + message:@"Argument type of startRequest is not array" + details:call.arguments]); + return; + } + NSMutableDictionary *properties = [NSMutableDictionary new]; + properties[SKReceiptPropertyIsExpired] = arguments[@"isExpired"]; + properties[SKReceiptPropertyIsRevoked] = arguments[@"isRevoked"]; + properties[SKReceiptPropertyIsVolumePurchase] = arguments[@"isVolumePurchase"]; + request = [self getRefreshReceiptRequest:properties]; + } else { + request = [self getRefreshReceiptRequest:nil]; + } + FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + [self.requestHandlers addObject:handler]; + __weak typeof(self) weakSelf = self; + [handler startProductRequestWithCompletionHandler:^(SKProductsResponse *_Nullable response, + NSError *_Nullable error) { + if (error) { + result([FlutterError errorWithCode:@"storekit_refreshreceiptrequest_platform_error" + message:error.localizedDescription + details:error.description]); + return; + } + result(nil); + [weakSelf.requestHandlers removeObject:handler]; + }]; +} + +#pragma mark - delegates: + +- (void)handleTransactionsUpdated:(NSArray *)transactions { + NSMutableArray *maps = [NSMutableArray new]; + for (SKPaymentTransaction *transaction in transactions) { + [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:transaction]]; + } + [self.callbackChannel invokeMethod:@"updatedTransactions" arguments:maps]; +} + +- (void)handleTransactionsRemoved:(NSArray *)transactions { + NSMutableArray *maps = [NSMutableArray new]; + for (SKPaymentTransaction *transaction in transactions) { + [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:transaction]]; + } + [self.callbackChannel invokeMethod:@"removedTransactions" arguments:maps]; +} + +- (void)handleTransactionRestoreFailed:(NSError *)error { + [self.callbackChannel invokeMethod:@"restoreCompletedTransactionsFailed" + arguments:[FIAObjectTranslator getMapFromNSError:error]]; +} + +- (void)restoreCompletedTransactionsFinished { + [self.callbackChannel invokeMethod:@"paymentQueueRestoreCompletedTransactionsFinished" + arguments:nil]; +} + +- (void)updatedDownloads:(NSArray *)downloads { + NSLog(@"Received an updatedDownloads callback, but downloads are not supported."); +} + +- (BOOL)shouldAddStorePayment:(SKPayment *)payment product:(SKProduct *)product { + // We always return NO here. And we send the message to dart to process the payment; and we will + // have a interception method that deciding if the payment should be processed (implemented by the + // programmer). + [self.productsCache setObject:product forKey:product.productIdentifier]; + [self.callbackChannel invokeMethod:@"shouldAddStorePayment" + arguments:@{ + @"payment" : [FIAObjectTranslator getMapFromSKPayment:payment], + @"product" : [FIAObjectTranslator getMapFromSKProduct:product] + }]; + return NO; +} + +#pragma mark - dependency injection (for unit testing) + +- (SKProductsRequest *)getProductRequestWithIdentifiers:(NSSet *)identifiers { + return [[SKProductsRequest alloc] initWithProductIdentifiers:identifiers]; +} + +- (SKProduct *)getProduct:(NSString *)productID { + return [self.productsCache objectForKey:productID]; +} + +- (SKReceiptRefreshRequest *)getRefreshReceiptRequest:(NSDictionary *)properties { + return [[SKReceiptRefreshRequest alloc] initWithReceiptProperties:properties]; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/InAppPurchasePluginTest.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/InAppPurchasePluginTest.m new file mode 100644 index 000000000000..cb00cbc2a43e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/InAppPurchasePluginTest.m @@ -0,0 +1,304 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import "FIAPaymentQueueHandler.h" +#import "Stubs.h" + +@import in_app_purchase; + +@interface InAppPurchasePluginTest : XCTestCase + +@property(strong, nonatomic) InAppPurchasePlugin* plugin; + +@end + +@implementation InAppPurchasePluginTest + +- (void)setUp { + self.plugin = + [[InAppPurchasePluginStub alloc] initWithReceiptManager:[FIAPReceiptManagerStub new]]; +} + +- (void)tearDown { +} + +- (void)testInvalidMethodCall { + XCTestExpectation* expectation = + [self expectationWithDescription:@"expect result to be not implemented"]; + FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:@"invalid" arguments:NULL]; + __block id result; + [self.plugin handleMethodCall:call + result:^(id r) { + [expectation fulfill]; + result = r; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(result, FlutterMethodNotImplemented); +} + +- (void)testCanMakePayments { + XCTestExpectation* expectation = [self expectationWithDescription:@"expect result to be YES"]; + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue canMakePayments:]" + arguments:NULL]; + __block id result; + [self.plugin handleMethodCall:call + result:^(id r) { + [expectation fulfill]; + result = r; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(result, [NSNumber numberWithBool:YES]); +} + +- (void)testGetProductResponse { + XCTestExpectation* expectation = + [self expectationWithDescription:@"expect response contains 1 item"]; + FlutterMethodCall* call = [FlutterMethodCall + methodCallWithMethodName:@"-[InAppPurchasePlugin startProductRequest:result:]" + arguments:@[ @"123" ]]; + __block id result; + [self.plugin handleMethodCall:call + result:^(id r) { + [expectation fulfill]; + result = r; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssert([result isKindOfClass:[NSDictionary class]]); + NSArray* resultArray = [result objectForKey:@"products"]; + XCTAssertEqual(resultArray.count, 1); + XCTAssertTrue([resultArray.firstObject[@"productIdentifier"] isEqualToString:@"123"]); +} + +- (void)testAddPaymentFailure { + XCTestExpectation* expectation = + [self expectationWithDescription:@"result should return failed state"]; + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]" + arguments:@{ + @"productIdentifier" : @"123", + @"quantity" : @(1), + @"simulatesAskToBuyInSandbox" : @YES, + }]; + SKPaymentQueueStub* queue = [SKPaymentQueueStub new]; + queue.testState = SKPaymentTransactionStateFailed; + __block SKPaymentTransaction* transactionForUpdateBlock; + self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray* _Nonnull transactions) { + SKPaymentTransaction* transaction = transactions[0]; + if (transaction.transactionState == SKPaymentTransactionStateFailed) { + transactionForUpdateBlock = transaction; + [expectation fulfill]; + } + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment* _Nonnull payment, SKProduct* _Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:self.plugin.paymentQueueHandler]; + + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStateFailed); +} + +- (void)testAddPaymentSuccessWithMockQueue { + XCTestExpectation* expectation = + [self expectationWithDescription:@"result should return success state"]; + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]" + arguments:@{ + @"productIdentifier" : @"123", + @"quantity" : @(1), + @"simulatesAskToBuyInSandbox" : @YES, + }]; + SKPaymentQueueStub* queue = [SKPaymentQueueStub new]; + queue.testState = SKPaymentTransactionStatePurchased; + __block SKPaymentTransaction* transactionForUpdateBlock; + self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray* _Nonnull transactions) { + SKPaymentTransaction* transaction = transactions[0]; + if (transaction.transactionState == SKPaymentTransactionStatePurchased) { + transactionForUpdateBlock = transaction; + [expectation fulfill]; + } + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment* _Nonnull payment, SKProduct* _Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:self.plugin.paymentQueueHandler]; + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStatePurchased); +} + +- (void)testAddPaymentWithNullSandboxArgument { + XCTestExpectation* expectation = + [self expectationWithDescription:@"result should return success state"]; + XCTestExpectation* simulatesAskToBuyInSandboxExpectation = + [self expectationWithDescription:@"payment isn't simulatesAskToBuyInSandbox"]; + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]" + arguments:@{ + @"productIdentifier" : @"123", + @"quantity" : @(1), + @"simulatesAskToBuyInSandbox" : [NSNull null], + }]; + SKPaymentQueueStub* queue = [SKPaymentQueueStub new]; + queue.testState = SKPaymentTransactionStatePurchased; + __block SKPaymentTransaction* transactionForUpdateBlock; + self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray* _Nonnull transactions) { + SKPaymentTransaction* transaction = transactions[0]; + if (transaction.transactionState == SKPaymentTransactionStatePurchased) { + transactionForUpdateBlock = transaction; + [expectation fulfill]; + } + if (@available(iOS 8.3, *)) { + if (!transaction.payment.simulatesAskToBuyInSandbox) { + [simulatesAskToBuyInSandboxExpectation fulfill]; + } + } else { + [simulatesAskToBuyInSandboxExpectation fulfill]; + } + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment* _Nonnull payment, SKProduct* _Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:self.plugin.paymentQueueHandler]; + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + [self waitForExpectations:@[ expectation, simulatesAskToBuyInSandboxExpectation ] timeout:5]; + XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStatePurchased); +} + +- (void)testRestoreTransactions { + XCTestExpectation* expectation = + [self expectationWithDescription:@"result successfully restore transactions"]; + FlutterMethodCall* call = [FlutterMethodCall + methodCallWithMethodName:@"-[InAppPurchasePlugin restoreTransactions:result:]" + arguments:nil]; + SKPaymentQueueStub* queue = [SKPaymentQueueStub new]; + queue.testState = SKPaymentTransactionStatePurchased; + __block BOOL callbackInvoked = NO; + self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray* _Nonnull transactions) { + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:^() { + callbackInvoked = YES; + [expectation fulfill]; + } + shouldAddStorePayment:nil + updatedDownloads:nil]; + [queue addTransactionObserver:self.plugin.paymentQueueHandler]; + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertTrue(callbackInvoked); +} + +- (void)testRetrieveReceiptData { + XCTestExpectation* expectation = [self expectationWithDescription:@"receipt data retrieved"]; + FlutterMethodCall* call = [FlutterMethodCall + methodCallWithMethodName:@"-[InAppPurchasePlugin retrieveReceiptData:result:]" + arguments:nil]; + __block NSDictionary* result; + [self.plugin handleMethodCall:call + result:^(id r) { + result = r; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + NSLog(@"%@", result); + XCTAssertNotNil(result); +} + +- (void)testRefreshReceiptRequest { + XCTestExpectation* expectation = [self expectationWithDescription:@"expect success"]; + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin refreshReceipt:result:]" + arguments:nil]; + __block BOOL result = NO; + [self.plugin handleMethodCall:call + result:^(id r) { + result = YES; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertTrue(result); +} + +- (void)testPresentCodeRedemptionSheet { + XCTestExpectation* expectation = + [self expectationWithDescription:@"expect successfully present Code Redemption Sheet"]; + FlutterMethodCall* call = [FlutterMethodCall + methodCallWithMethodName:@"-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]" + arguments:nil]; + __block BOOL callbackInvoked = NO; + [self.plugin handleMethodCall:call + result:^(id r) { + callbackInvoked = YES; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertTrue(callbackInvoked); +} + +- (void)testGetPendingTransactions { + XCTestExpectation* expectation = [self expectationWithDescription:@"expect success"]; + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue transactions]" arguments:nil]; + SKPaymentQueue* mockQueue = OCMClassMock(SKPaymentQueue.class); + NSDictionary* transactionMap = @{ + @"transactionIdentifier" : [NSNull null], + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + @"originalTransaction" : [NSNull null], + }; + OCMStub(mockQueue.transactions).andReturn(@[ [[SKPaymentTransactionStub alloc] + initWithMap:transactionMap] ]); + + __block NSArray* resultArray; + self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:mockQueue + transactionsUpdated:nil + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:nil + updatedDownloads:nil]; + [self.plugin handleMethodCall:call + result:^(id r) { + resultArray = r; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqualObjects(resultArray, @[ transactionMap ]); +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/PaymentQueueTest.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/PaymentQueueTest.m new file mode 100644 index 000000000000..c335fa3ef307 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/PaymentQueueTest.m @@ -0,0 +1,212 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import "Stubs.h" + +@import in_app_purchase; + +@interface PaymentQueueTest : XCTestCase + +@property(strong, nonatomic) NSDictionary *periodMap; +@property(strong, nonatomic) NSDictionary *discountMap; +@property(strong, nonatomic) NSDictionary *productMap; +@property(strong, nonatomic) NSDictionary *productResponseMap; + +@end + +@implementation PaymentQueueTest + +- (void)setUp { + self.periodMap = @{@"numberOfUnits" : @(0), @"unit" : @(0)}; + self.discountMap = @{ + @"price" : @1.0, + @"currencyCode" : @"USD", + @"numberOfPeriods" : @1, + @"subscriptionPeriod" : self.periodMap, + @"paymentMode" : @1 + }; + self.productMap = @{ + @"price" : @1.0, + @"currencyCode" : @"USD", + @"productIdentifier" : @"123", + @"localizedTitle" : @"title", + @"localizedDescription" : @"des", + @"subscriptionPeriod" : self.periodMap, + @"introductoryPrice" : self.discountMap, + @"subscriptionGroupIdentifier" : @"com.group" + }; + self.productResponseMap = + @{@"products" : @[ self.productMap ], @"invalidProductIdentifiers" : [NSNull null]}; +} + +- (void)testTransactionPurchased { + XCTestExpectation *expectation = + [self expectationWithDescription:@"expect to get purchased transcation."]; + SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init]; + queue.testState = SKPaymentTransactionStatePurchased; + __block SKPaymentTransactionStub *tran; + FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray *_Nonnull transactions) { + SKPaymentTransaction *transaction = transactions[0]; + tran = (SKPaymentTransactionStub *)transaction; + [expectation fulfill]; + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:handler]; + SKPayment *payment = + [SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]]; + [handler addPayment:payment]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(tran.transactionState, SKPaymentTransactionStatePurchased); + XCTAssertEqual(tran.transactionIdentifier, @"fakeID"); +} + +- (void)testTransactionFailed { + XCTestExpectation *expectation = + [self expectationWithDescription:@"expect to get failed transcation."]; + SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init]; + queue.testState = SKPaymentTransactionStateFailed; + __block SKPaymentTransactionStub *tran; + FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray *_Nonnull transactions) { + SKPaymentTransaction *transaction = transactions[0]; + tran = (SKPaymentTransactionStub *)transaction; + [expectation fulfill]; + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:handler]; + SKPayment *payment = + [SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]]; + [handler addPayment:payment]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(tran.transactionState, SKPaymentTransactionStateFailed); + XCTAssertEqual(tran.transactionIdentifier, nil); +} + +- (void)testTransactionRestored { + XCTestExpectation *expectation = + [self expectationWithDescription:@"expect to get restored transcation."]; + SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init]; + queue.testState = SKPaymentTransactionStateRestored; + __block SKPaymentTransactionStub *tran; + FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray *_Nonnull transactions) { + SKPaymentTransaction *transaction = transactions[0]; + tran = (SKPaymentTransactionStub *)transaction; + [expectation fulfill]; + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:handler]; + SKPayment *payment = + [SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]]; + [handler addPayment:payment]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(tran.transactionState, SKPaymentTransactionStateRestored); + XCTAssertEqual(tran.transactionIdentifier, @"fakeID"); +} + +- (void)testTransactionPurchasing { + XCTestExpectation *expectation = + [self expectationWithDescription:@"expect to get purchasing transcation."]; + SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init]; + queue.testState = SKPaymentTransactionStatePurchasing; + __block SKPaymentTransactionStub *tran; + FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray *_Nonnull transactions) { + SKPaymentTransaction *transaction = transactions[0]; + tran = (SKPaymentTransactionStub *)transaction; + [expectation fulfill]; + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:handler]; + SKPayment *payment = + [SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]]; + [handler addPayment:payment]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(tran.transactionState, SKPaymentTransactionStatePurchasing); + XCTAssertEqual(tran.transactionIdentifier, nil); +} + +- (void)testTransactionDeferred { + XCTestExpectation *expectation = + [self expectationWithDescription:@"expect to get deffered transcation."]; + SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init]; + queue.testState = SKPaymentTransactionStateDeferred; + __block SKPaymentTransactionStub *tran; + FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray *_Nonnull transactions) { + SKPaymentTransaction *transaction = transactions[0]; + tran = (SKPaymentTransactionStub *)transaction; + [expectation fulfill]; + } + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:handler]; + SKPayment *payment = + [SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]]; + [handler addPayment:payment]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(tran.transactionState, SKPaymentTransactionStateDeferred); + XCTAssertEqual(tran.transactionIdentifier, nil); +} + +- (void)testFinishTransaction { + XCTestExpectation *expectation = + [self expectationWithDescription:@"handler.transactions should be empty."]; + SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init]; + queue.testState = SKPaymentTransactionStateDeferred; + __block FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray *_Nonnull transactions) { + XCTAssertEqual(transactions.count, 1); + SKPaymentTransaction *transaction = transactions[0]; + [handler finishTransaction:transaction]; + } + transactionRemoved:^(NSArray *_Nonnull transactions) { + XCTAssertEqual(transactions.count, 1); + [expectation fulfill]; + } + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) { + return YES; + } + updatedDownloads:nil]; + [queue addTransactionObserver:handler]; + SKPayment *payment = + [SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]]; + [handler addPayment:payment]; + [self waitForExpectations:@[ expectation ] timeout:5]; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/ProductRequestHandlerTest.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/ProductRequestHandlerTest.m new file mode 100644 index 000000000000..19f5848b7168 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/ProductRequestHandlerTest.m @@ -0,0 +1,89 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import "Stubs.h" + +@import in_app_purchase; + +#pragma tests start here + +@interface RequestHandlerTest : XCTestCase + +@end + +@implementation RequestHandlerTest + +- (void)testRequestHandlerWithProductRequestSuccess { + SKProductRequestStub *request = + [[SKProductRequestStub alloc] initWithProductIdentifiers:[NSSet setWithArray:@[ @"123" ]]]; + FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + XCTestExpectation *expectation = + [self expectationWithDescription:@"expect to get response with 1 product"]; + __block SKProductsResponse *response; + [handler + startProductRequestWithCompletionHandler:^(SKProductsResponse *_Nullable r, NSError *error) { + response = r; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertNotNil(response); + XCTAssertEqual(response.products.count, 1); + SKProduct *product = response.products.firstObject; + XCTAssertTrue([product.productIdentifier isEqualToString:@"123"]); +} + +- (void)testRequestHandlerWithProductRequestFailure { + SKProductRequestStub *request = [[SKProductRequestStub alloc] + initWithFailureError:[NSError errorWithDomain:@"test" code:123 userInfo:@{}]]; + FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + XCTestExpectation *expectation = + [self expectationWithDescription:@"expect to get response with 1 product"]; + __block NSError *error; + __block SKProductsResponse *response; + [handler startProductRequestWithCompletionHandler:^(SKProductsResponse *_Nullable r, NSError *e) { + error = e; + response = r; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertNotNil(error); + XCTAssertEqual(error.domain, @"test"); + XCTAssertNil(response); +} + +- (void)testRequestHandlerWithRefreshReceiptSuccess { + SKReceiptRefreshRequestStub *request = + [[SKReceiptRefreshRequestStub alloc] initWithReceiptProperties:nil]; + FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + XCTestExpectation *expectation = [self expectationWithDescription:@"expect no error"]; + __block NSError *e; + [handler + startProductRequestWithCompletionHandler:^(SKProductsResponse *_Nullable r, NSError *error) { + e = error; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertNil(e); +} + +- (void)testRequestHandlerWithRefreshReceiptFailure { + SKReceiptRefreshRequestStub *request = [[SKReceiptRefreshRequestStub alloc] + initWithFailureError:[NSError errorWithDomain:@"test" code:123 userInfo:@{}]]; + FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + XCTestExpectation *expectation = [self expectationWithDescription:@"expect error"]; + __block NSError *error; + __block SKProductsResponse *response; + [handler startProductRequestWithCompletionHandler:^(SKProductsResponse *_Nullable r, NSError *e) { + error = e; + response = r; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertNotNil(error); + XCTAssertEqual(error.domain, @"test"); + XCTAssertNil(response); +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.h new file mode 100644 index 000000000000..e07cc3f5a147 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.h @@ -0,0 +1,62 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +@import in_app_purchase; + +NS_ASSUME_NONNULL_BEGIN +API_AVAILABLE(ios(11.2), macos(10.13.2)) +@interface SKProductSubscriptionPeriodStub : SKProductSubscriptionPeriod +- (instancetype)initWithMap:(NSDictionary *)map; +@end + +API_AVAILABLE(ios(11.2), macos(10.13.2)) +@interface SKProductDiscountStub : SKProductDiscount +- (instancetype)initWithMap:(NSDictionary *)map; +@end + +@interface SKProductStub : SKProduct +- (instancetype)initWithMap:(NSDictionary *)map; +@end + +@interface SKProductRequestStub : SKProductsRequest +- (instancetype)initWithProductIdentifiers:(NSSet *)productIdentifiers; +- (instancetype)initWithFailureError:(NSError *)error; +@end + +@interface SKProductsResponseStub : SKProductsResponse +- (instancetype)initWithMap:(NSDictionary *)map; +@end + +@interface InAppPurchasePluginStub : InAppPurchasePlugin +@end + +@interface SKPaymentQueueStub : SKPaymentQueue +@property(assign, nonatomic) SKPaymentTransactionState testState; +@end + +@interface SKPaymentTransactionStub : SKPaymentTransaction +- (instancetype)initWithMap:(NSDictionary *)map; +- (instancetype)initWithState:(SKPaymentTransactionState)state; +- (instancetype)initWithState:(SKPaymentTransactionState)state payment:(SKPayment *)payment; +@end + +@interface SKMutablePaymentStub : SKMutablePayment +- (instancetype)initWithMap:(NSDictionary *)map; +@end + +@interface NSErrorStub : NSError +- (instancetype)initWithMap:(NSDictionary *)map; +@end + +@interface FIAPReceiptManagerStub : FIAPReceiptManager +@end + +@interface SKReceiptRefreshRequestStub : SKReceiptRefreshRequest +- (instancetype)initWithFailureError:(NSError *)error; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.m new file mode 100644 index 000000000000..66610a88a77d --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.m @@ -0,0 +1,290 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "Stubs.h" + +@implementation SKProductSubscriptionPeriodStub + +- (instancetype)initWithMap:(NSDictionary *)map { + self = [super init]; + if (self) { + [self setValue:map[@"numberOfUnits"] ?: @(0) forKey:@"numberOfUnits"]; + [self setValue:map[@"unit"] ?: @(0) forKey:@"unit"]; + } + return self; +} + +@end + +@implementation SKProductDiscountStub + +- (instancetype)initWithMap:(NSDictionary *)map { + self = [super init]; + if (self) { + [self setValue:[[NSDecimalNumber alloc] initWithString:map[@"price"]] ?: [NSNull null] + forKey:@"price"]; + NSLocale *locale = NSLocale.systemLocale; + [self setValue:locale ?: [NSNull null] forKey:@"priceLocale"]; + [self setValue:map[@"numberOfPeriods"] ?: @(0) forKey:@"numberOfPeriods"]; + SKProductSubscriptionPeriodStub *subscriptionPeriodSub = + [[SKProductSubscriptionPeriodStub alloc] initWithMap:map[@"subscriptionPeriod"]]; + [self setValue:subscriptionPeriodSub forKey:@"subscriptionPeriod"]; + [self setValue:map[@"paymentMode"] ?: @(0) forKey:@"paymentMode"]; + } + return self; +} + +@end + +@implementation SKProductStub + +- (instancetype)initWithMap:(NSDictionary *)map { + self = [super init]; + if (self) { + [self setValue:map[@"productIdentifier"] ?: [NSNull null] forKey:@"productIdentifier"]; + [self setValue:map[@"localizedDescription"] ?: [NSNull null] forKey:@"localizedDescription"]; + [self setValue:map[@"localizedTitle"] ?: [NSNull null] forKey:@"localizedTitle"]; + [self setValue:map[@"downloadable"] ?: @NO forKey:@"downloadable"]; + [self setValue:[[NSDecimalNumber alloc] initWithString:map[@"price"]] ?: [NSNull null] + forKey:@"price"]; + NSLocale *locale = NSLocale.systemLocale; + [self setValue:locale ?: [NSNull null] forKey:@"priceLocale"]; + [self setValue:map[@"downloadContentLengths"] ?: @(0) forKey:@"downloadContentLengths"]; + if (@available(iOS 11.2, *)) { + SKProductSubscriptionPeriodStub *period = + [[SKProductSubscriptionPeriodStub alloc] initWithMap:map[@"subscriptionPeriod"]]; + [self setValue:period ?: [NSNull null] forKey:@"subscriptionPeriod"]; + SKProductDiscountStub *discount = + [[SKProductDiscountStub alloc] initWithMap:map[@"introductoryPrice"]]; + [self setValue:discount ?: [NSNull null] forKey:@"introductoryPrice"]; + [self setValue:map[@"subscriptionGroupIdentifier"] ?: [NSNull null] + forKey:@"subscriptionGroupIdentifier"]; + } + } + return self; +} + +- (instancetype)initWithProductID:(NSString *)productIdentifier { + self = [super init]; + if (self) { + [self setValue:productIdentifier forKey:@"productIdentifier"]; + } + return self; +} + +@end + +@interface SKProductRequestStub () + +@property(strong, nonatomic) NSSet *identifers; +@property(strong, nonatomic) NSError *error; + +@end + +@implementation SKProductRequestStub + +- (instancetype)initWithProductIdentifiers:(NSSet *)productIdentifiers { + self = [super initWithProductIdentifiers:productIdentifiers]; + self.identifers = productIdentifiers; + return self; +} + +- (instancetype)initWithFailureError:(NSError *)error { + self = [super init]; + self.error = error; + return self; +} + +- (void)start { + NSMutableArray *productArray = [NSMutableArray new]; + for (NSString *identifier in self.identifers) { + [productArray addObject:@{@"productIdentifier" : identifier}]; + } + SKProductsResponseStub *response = + [[SKProductsResponseStub alloc] initWithMap:@{@"products" : productArray}]; + if (self.error) { + [self.delegate request:self didFailWithError:self.error]; + } else { + [self.delegate productsRequest:self didReceiveResponse:response]; + } +} + +@end + +@implementation SKProductsResponseStub + +- (instancetype)initWithMap:(NSDictionary *)map { + self = [super init]; + if (self) { + NSMutableArray *products = [NSMutableArray new]; + for (NSDictionary *productMap in map[@"products"]) { + SKProductStub *product = [[SKProductStub alloc] initWithMap:productMap]; + [products addObject:product]; + } + [self setValue:products forKey:@"products"]; + } + return self; +} + +@end + +@interface InAppPurchasePluginStub () + +@end + +@implementation InAppPurchasePluginStub + +- (SKProductRequestStub *)getProductRequestWithIdentifiers:(NSSet *)identifiers { + return [[SKProductRequestStub alloc] initWithProductIdentifiers:identifiers]; +} + +- (SKProduct *)getProduct:(NSString *)productID { + return [[SKProductStub alloc] initWithProductID:productID]; +} + +- (SKReceiptRefreshRequestStub *)getRefreshReceiptRequest:(NSDictionary *)properties { + return [[SKReceiptRefreshRequestStub alloc] initWithReceiptProperties:properties]; +} + +@end + +@interface SKPaymentQueueStub () + +@property(strong, nonatomic) id observer; + +@end + +@implementation SKPaymentQueueStub + +- (void)addTransactionObserver:(id)observer { + self.observer = observer; +} + +- (void)addPayment:(SKPayment *)payment { + SKPaymentTransactionStub *transaction = + [[SKPaymentTransactionStub alloc] initWithState:self.testState payment:payment]; + [self.observer paymentQueue:self updatedTransactions:@[ transaction ]]; +} + +- (void)restoreCompletedTransactions { + if ([self.observer + respondsToSelector:@selector(paymentQueueRestoreCompletedTransactionsFinished:)]) { + [self.observer paymentQueueRestoreCompletedTransactionsFinished:self]; + } +} + +- (void)finishTransaction:(SKPaymentTransaction *)transaction { + if ([self.observer respondsToSelector:@selector(paymentQueue:removedTransactions:)]) { + [self.observer paymentQueue:self removedTransactions:@[ transaction ]]; + } +} + +@end + +@implementation SKPaymentTransactionStub { + SKPayment *_payment; +} + +- (instancetype)initWithID:(NSString *)identifier { + self = [super init]; + if (self) { + [self setValue:identifier forKey:@"transactionIdentifier"]; + } + return self; +} + +- (instancetype)initWithMap:(NSDictionary *)map { + self = [super init]; + if (self) { + [self setValue:map[@"transactionIdentifier"] forKey:@"transactionIdentifier"]; + [self setValue:map[@"transactionState"] forKey:@"transactionState"]; + if (![map[@"originalTransaction"] isKindOfClass:[NSNull class]] && + map[@"originalTransaction"]) { + [self setValue:[[SKPaymentTransactionStub alloc] initWithMap:map[@"originalTransaction"]] + forKey:@"originalTransaction"]; + } + [self setValue:map[@"error"] ? [[NSErrorStub alloc] initWithMap:map[@"error"]] : [NSNull null] + forKey:@"error"]; + [self setValue:[NSDate dateWithTimeIntervalSince1970:[map[@"transactionTimeStamp"] doubleValue]] + forKey:@"transactionDate"]; + } + return self; +} + +- (instancetype)initWithState:(SKPaymentTransactionState)state { + self = [super init]; + if (self) { + // Only purchased and restored transactions have transactionIdentifier: + // https://developer.apple.com/documentation/storekit/skpaymenttransaction/1411288-transactionidentifier?language=objc + if (state == SKPaymentTransactionStatePurchased || state == SKPaymentTransactionStateRestored) { + [self setValue:@"fakeID" forKey:@"transactionIdentifier"]; + } + [self setValue:@(state) forKey:@"transactionState"]; + } + return self; +} + +- (instancetype)initWithState:(SKPaymentTransactionState)state payment:(SKPayment *)payment { + self = [super init]; + if (self) { + // Only purchased and restored transactions have transactionIdentifier: + // https://developer.apple.com/documentation/storekit/skpaymenttransaction/1411288-transactionidentifier?language=objc + if (state == SKPaymentTransactionStatePurchased || state == SKPaymentTransactionStateRestored) { + [self setValue:@"fakeID" forKey:@"transactionIdentifier"]; + } + [self setValue:@(state) forKey:@"transactionState"]; + _payment = payment; + } + return self; +} + +- (SKPayment *)payment { + return _payment; +} + +@end + +@implementation NSErrorStub + +- (instancetype)initWithMap:(NSDictionary *)map { + return [self initWithDomain:[map objectForKey:@"domain"] + code:[[map objectForKey:@"code"] integerValue] + userInfo:[map objectForKey:@"userInfo"]]; +} + +@end + +@implementation FIAPReceiptManagerStub : FIAPReceiptManager + +- (NSData *)getReceiptData:(NSURL *)url { + NSString *originalString = [NSString stringWithFormat:@"test"]; + return [[NSData alloc] initWithBase64EncodedString:originalString options:kNilOptions]; +} + +@end + +@implementation SKReceiptRefreshRequestStub { + NSError *_error; +} + +- (instancetype)initWithReceiptProperties:(NSDictionary *)properties { + self = [super initWithReceiptProperties:properties]; + return self; +} + +- (instancetype)initWithFailureError:(NSError *)error { + self = [super init]; + _error = error; + return self; +} + +- (void)start { + if (_error) { + [self.delegate request:self didFailWithError:_error]; + } else { + [self.delegate requestDidFinish:self]; + } +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/TranslatorTest.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/TranslatorTest.m new file mode 100644 index 000000000000..550d1fc341c6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/TranslatorTest.m @@ -0,0 +1,147 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import "Stubs.h" + +@import in_app_purchase; + +@interface TranslatorTest : XCTestCase + +@property(strong, nonatomic) NSDictionary *periodMap; +@property(strong, nonatomic) NSDictionary *discountMap; +@property(strong, nonatomic) NSMutableDictionary *productMap; +@property(strong, nonatomic) NSDictionary *productResponseMap; +@property(strong, nonatomic) NSDictionary *paymentMap; +@property(strong, nonatomic) NSDictionary *transactionMap; +@property(strong, nonatomic) NSDictionary *errorMap; +@property(strong, nonatomic) NSDictionary *localeMap; + +@end + +@implementation TranslatorTest + +- (void)setUp { + self.periodMap = @{@"numberOfUnits" : @(0), @"unit" : @(0)}; + self.discountMap = @{ + @"price" : @"1", + @"priceLocale" : [FIAObjectTranslator getMapFromNSLocale:NSLocale.systemLocale], + @"numberOfPeriods" : @1, + @"subscriptionPeriod" : self.periodMap, + @"paymentMode" : @1 + }; + + self.productMap = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"price" : @"1", + @"priceLocale" : [FIAObjectTranslator getMapFromNSLocale:NSLocale.systemLocale], + @"productIdentifier" : @"123", + @"localizedTitle" : @"title", + @"localizedDescription" : @"des", + }]; + if (@available(iOS 11.2, *)) { + self.productMap[@"subscriptionPeriod"] = self.periodMap; + self.productMap[@"introductoryPrice"] = self.discountMap; + } + + if (@available(iOS 12.0, *)) { + self.productMap[@"subscriptionGroupIdentifier"] = @"com.group"; + } + + self.productResponseMap = + @{@"products" : @[ self.productMap ], @"invalidProductIdentifiers" : @[]}; + self.paymentMap = @{ + @"productIdentifier" : @"123", + @"requestData" : @"abcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefgh", + @"quantity" : @(2), + @"applicationUsername" : @"app user name", + @"simulatesAskToBuyInSandbox" : @(NO) + }; + NSDictionary *originalTransactionMap = @{ + @"transactionIdentifier" : @"567", + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + @"originalTransaction" : [NSNull null], + }; + self.transactionMap = @{ + @"transactionIdentifier" : @"567", + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + @"originalTransaction" : originalTransactionMap, + }; + self.errorMap = @{ + @"code" : @(123), + @"domain" : @"test_domain", + @"userInfo" : @{ + @"key" : @"value", + } + }; +} + +- (void)testSKProductSubscriptionPeriodStubToMap { + if (@available(iOS 11.2, *)) { + SKProductSubscriptionPeriodStub *period = + [[SKProductSubscriptionPeriodStub alloc] initWithMap:self.periodMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKProductSubscriptionPeriod:period]; + XCTAssertEqualObjects(map, self.periodMap); + } +} + +- (void)testSKProductDiscountStubToMap { + if (@available(iOS 11.2, *)) { + SKProductDiscountStub *discount = [[SKProductDiscountStub alloc] initWithMap:self.discountMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKProductDiscount:discount]; + XCTAssertEqualObjects(map, self.discountMap); + } +} + +- (void)testProductToMap { + SKProductStub *product = [[SKProductStub alloc] initWithMap:self.productMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKProduct:product]; + XCTAssertEqualObjects(map, self.productMap); +} + +- (void)testProductResponseToMap { + SKProductsResponseStub *response = + [[SKProductsResponseStub alloc] initWithMap:self.productResponseMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKProductsResponse:response]; + XCTAssertEqualObjects(map, self.productResponseMap); +} + +- (void)testPaymentToMap { + SKMutablePayment *payment = [FIAObjectTranslator getSKMutablePaymentFromMap:self.paymentMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKPayment:payment]; + XCTAssertEqualObjects(map, self.paymentMap); +} + +- (void)testPaymentTransactionToMap { + // payment is not KVC, cannot test payment field. + SKPaymentTransactionStub *paymentTransaction = + [[SKPaymentTransactionStub alloc] initWithMap:self.transactionMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKPaymentTransaction:paymentTransaction]; + XCTAssertEqualObjects(map, self.transactionMap); +} + +- (void)testError { + NSErrorStub *error = [[NSErrorStub alloc] initWithMap:self.errorMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromNSError:error]; + XCTAssertEqualObjects(map, self.errorMap); +} + +- (void)testLocaleToMap { + if (@available(iOS 10.0, *)) { + NSLocale *system = NSLocale.systemLocale; + NSDictionary *map = [FIAObjectTranslator getMapFromNSLocale:system]; + XCTAssertEqualObjects(map[@"currencySymbol"], system.currencySymbol); + } +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase.podspec b/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase.podspec new file mode 100644 index 000000000000..4a423dd036cf --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'in_app_purchase' + s.version = '0.0.1' + s.summary = 'Flutter In App Purchase' + s.description = <<-DESC +A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store. +Downloaded by pub (not CocoaPods). + DESC + s.homepage = 'https://github.com/flutter/plugins' + s.license = { :type => 'BSD', :file => '../LICENSE' } + s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } + s.source = { :http => 'https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios' } + # TODO(mvanbeusekom): update URL when in_app_purchase_ios package is published. + # Updating it before the package is published will cause a lint error and block the tree. + s.documentation_url = 'https://pub.dev/packages/in_app_purchase' + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.platform = :ios, '8.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS' => 'armv7 arm64 x86_64' } + + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = 'Tests/**/*' + test_spec.dependency 'OCMock','3.5' + end +end diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/in_app_purchase_ios.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/in_app_purchase_ios.dart new file mode 100644 index 000000000000..21e76815e6ac --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/in_app_purchase_ios.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/in_app_purchase_ios_platform.dart'; +export 'src/in_app_purchase_ios_platform_addition.dart'; +export 'src/types/types.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart new file mode 100644 index 000000000000..f8ab4d48be7e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart @@ -0,0 +1,9 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; + +/// Method channel for the plugin's platform<-->Dart calls. +const MethodChannel channel = + MethodChannel('plugins.flutter.io/in_app_purchase'); diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart new file mode 100644 index 000000000000..bb2fd2b3639a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart @@ -0,0 +1,212 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:in_app_purchase_ios/src/in_app_purchase_ios_platform_addition.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../in_app_purchase_ios.dart'; +import '../store_kit_wrappers.dart'; + +/// [IAPError.code] code for failed purchases. +const String kPurchaseErrorCode = 'purchase_error'; + +/// Indicates store front is Apple AppStore. +const String kIAPSource = 'app_store'; + +/// An [InAppPurchasePlatform] that wraps StoreKit. +/// +/// This translates various `StoreKit` calls and responses into the +/// generic plugin API. +class InAppPurchaseIosPlatform extends InAppPurchasePlatform { + /// Returns the singleton instance of the [InAppPurchaseIosPlatform] that should be + /// used across the app. + static InAppPurchaseIosPlatform get instance => _getOrCreateInstance(); + static InAppPurchaseIosPlatform? _instance; + static late SKPaymentQueueWrapper _skPaymentQueueWrapper; + static late _TransactionObserver _observer; + + /// Creates an [InAppPurchaseIosPlatform] object. + /// + /// This constructor should only be used for testing, for any other purpose + /// get the connection from the [instance] getter. + @visibleForTesting + InAppPurchaseIosPlatform(); + + Stream> get purchaseStream => + _observer.purchaseUpdatedController.stream; + + /// Callback handler for transaction status changes. + @visibleForTesting + static SKTransactionObserverWrapper get observer => _observer; + + static InAppPurchaseIosPlatform _getOrCreateInstance() { + if (_instance != null) { + return _instance!; + } + + // Register the [InAppPurchaseIosPlatformAddition] containing iOS + // platform-specific functionality. + InAppPurchasePlatformAddition.instance = InAppPurchaseIosPlatformAddition(); + + // Register the platform-specific implementation of the idiomatic + // InAppPurchase API. + _instance = InAppPurchaseIosPlatform(); + _skPaymentQueueWrapper = SKPaymentQueueWrapper(); + _observer = _TransactionObserver(StreamController.broadcast()); + _skPaymentQueueWrapper.setTransactionObserver(observer); + return _instance!; + } + + @override + Future isAvailable() => SKPaymentQueueWrapper.canMakePayments(); + + @override + Future buyNonConsumable({required PurchaseParam purchaseParam}) async { + await _skPaymentQueueWrapper.addPayment(SKPaymentWrapper( + productIdentifier: purchaseParam.productDetails.id, + quantity: 1, + applicationUsername: purchaseParam.applicationUserName, + simulatesAskToBuyInSandbox: (purchaseParam is AppStorePurchaseParam) + ? purchaseParam.simulatesAskToBuyInSandbox + : false, + requestData: null)); + + return true; // There's no error feedback from iOS here to return. + } + + @override + Future buyConsumable( + {required PurchaseParam purchaseParam, bool autoConsume = true}) { + assert(autoConsume == true, 'On iOS, we should always auto consume'); + return buyNonConsumable(purchaseParam: purchaseParam); + } + + @override + Future completePurchase(PurchaseDetails purchase) { + assert( + purchase is AppStorePurchaseDetails, + 'On iOS, the `purchase` should always be of type `AppStorePurchaseDetails`.', + ); + + return _skPaymentQueueWrapper.finishTransaction( + (purchase as AppStorePurchaseDetails).skPaymentTransaction, + ); + } + + @override + Future restorePurchases({String? applicationUserName}) async { + return _observer + .restoreTransactions( + queue: _skPaymentQueueWrapper, + applicationUserName: applicationUserName) + .whenComplete(() => _observer.cleanUpRestoredTransactions()); + } + + /// Query the product detail list. + /// + /// This method only returns [ProductDetailsResponse]. + /// To get detailed Store Kit product list, use [SkProductResponseWrapper.startProductRequest] + /// to get the [SKProductResponseWrapper]. + @override + Future queryProductDetails( + Set identifiers) async { + final SKRequestMaker requestMaker = SKRequestMaker(); + SkProductResponseWrapper response; + PlatformException? exception; + try { + response = await requestMaker.startProductRequest(identifiers.toList()); + } on PlatformException catch (e) { + exception = e; + response = SkProductResponseWrapper( + products: [], invalidProductIdentifiers: identifiers.toList()); + } + List productDetails = []; + if (response.products != null) { + productDetails = response.products + .map((SKProductWrapper productWrapper) => + AppStoreProductDetails.fromSKProduct(productWrapper)) + .toList(); + } + List invalidIdentifiers = response.invalidProductIdentifiers; + if (productDetails.isEmpty) { + invalidIdentifiers = identifiers.toList(); + } + ProductDetailsResponse productDetailsResponse = ProductDetailsResponse( + productDetails: productDetails, + notFoundIDs: invalidIdentifiers, + error: exception == null + ? null + : IAPError( + source: kIAPSource, + code: exception.code, + message: exception.message ?? '', + details: exception.details), + ); + return productDetailsResponse; + } +} + +class _TransactionObserver implements SKTransactionObserverWrapper { + final StreamController> purchaseUpdatedController; + + Completer? _restoreCompleter; + late String _receiptData; + + _TransactionObserver(this.purchaseUpdatedController); + + Future restoreTransactions({ + required SKPaymentQueueWrapper queue, + String? applicationUserName, + }) { + _restoreCompleter = Completer(); + queue.restoreTransactions(applicationUserName: applicationUserName); + return _restoreCompleter!.future; + } + + void cleanUpRestoredTransactions() { + _restoreCompleter = null; + } + + void updatedTransactions( + {required List transactions}) async { + String receiptData = await getReceiptData(); + List purchases = transactions + .map((SKPaymentTransactionWrapper transaction) => + AppStorePurchaseDetails.fromSKTransaction(transaction, receiptData)) + .toList(); + + purchaseUpdatedController.add(purchases); + } + + void removedTransactions( + {required List transactions}) {} + + /// Triggered when there is an error while restoring transactions. + void restoreCompletedTransactionsFailed({required SKError error}) { + _restoreCompleter!.completeError(error); + } + + void paymentQueueRestoreCompletedTransactionsFinished() { + _restoreCompleter!.complete(); + } + + bool shouldAddStorePayment( + {required SKPaymentWrapper payment, required SKProductWrapper product}) { + // In this unified API, we always return true to keep it consistent with the behavior on Google Play. + return true; + } + + Future getReceiptData() async { + try { + _receiptData = await SKReceiptManager.retrieveReceiptData(); + } catch (e) { + _receiptData = ''; + } + return _receiptData; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart new file mode 100644 index 000000000000..0c7b2de860b6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart @@ -0,0 +1,33 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../store_kit_wrappers.dart'; + +/// Contains InApp Purchase features that are only available on iOS. +class InAppPurchaseIosPlatformAddition extends InAppPurchasePlatformAddition { + /// Present Code Redemption Sheet. + /// + /// Available on devices running iOS 14 and iPadOS 14 and later. + Future presentCodeRedemptionSheet() { + return SKPaymentQueueWrapper().presentCodeRedemptionSheet(); + } + + /// Retry loading purchase data after an initial failure. + /// + /// If no results, a `null` value is returned. + Future refreshPurchaseVerificationData() async { + await SKRequestMaker().startRefreshReceiptRequest(); + final String? receipt = await SKReceiptManager.retrieveReceiptData(); + if (receipt == null) { + return null; + } + return PurchaseVerificationData( + localVerificationData: receipt, + serverVerificationData: receipt, + source: kIAPSource); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/README.md b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/README.md new file mode 100644 index 000000000000..bd8e5b540ba0 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/README.md @@ -0,0 +1,5 @@ +# store_kit_wrappers + +This exposes Dart endpoints through to the +[StoreKit](https://developer.apple.com/documentation/storekit) APIs. Can be used +as an alternative to [in_app_purchase](../in_app_purchase/README.md). \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart new file mode 100644 index 000000000000..08af2c6058c4 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart @@ -0,0 +1,109 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../../store_kit_wrappers.dart'; + +part 'enum_converters.g.dart'; + +/// Serializer for [SKPaymentTransactionStateWrapper]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@SKTransactionStatusConverter()`. +class SKTransactionStatusConverter + implements JsonConverter { + /// Default const constructor. + const SKTransactionStatusConverter(); + + @override + SKPaymentTransactionStateWrapper fromJson(int? json) { + if (json == null) { + return SKPaymentTransactionStateWrapper.unspecified; + } + return _$enumDecode( + _$SKPaymentTransactionStateWrapperEnumMap + .cast(), + json); + } + + /// Converts an [SKPaymentTransactionStateWrapper] to a [PurchaseStatus]. + PurchaseStatus toPurchaseStatus(SKPaymentTransactionStateWrapper object) { + switch (object) { + case SKPaymentTransactionStateWrapper.purchasing: + case SKPaymentTransactionStateWrapper.deferred: + return PurchaseStatus.pending; + case SKPaymentTransactionStateWrapper.purchased: + return PurchaseStatus.purchased; + case SKPaymentTransactionStateWrapper.restored: + return PurchaseStatus.restored; + case SKPaymentTransactionStateWrapper.failed: + case SKPaymentTransactionStateWrapper.unspecified: + return PurchaseStatus.error; + } + } + + @override + int toJson(SKPaymentTransactionStateWrapper object) => + _$SKPaymentTransactionStateWrapperEnumMap[object]!; +} + +/// Serializer for [SKSubscriptionPeriodUnit]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@SKSubscriptionPeriodUnitConverter()`. +class SKSubscriptionPeriodUnitConverter + implements JsonConverter { + /// Default const constructor. + const SKSubscriptionPeriodUnitConverter(); + + @override + SKSubscriptionPeriodUnit fromJson(int? json) { + if (json == null) { + return SKSubscriptionPeriodUnit.day; + } + return _$enumDecode( + _$SKSubscriptionPeriodUnitEnumMap + .cast(), + json); + } + + @override + int toJson(SKSubscriptionPeriodUnit object) => + _$SKSubscriptionPeriodUnitEnumMap[object]!; +} + +/// Serializer for [SKProductDiscountPaymentMode]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@SKProductDiscountPaymentModeConverter()`. +class SKProductDiscountPaymentModeConverter + implements JsonConverter { + /// Default const constructor. + const SKProductDiscountPaymentModeConverter(); + + @override + SKProductDiscountPaymentMode fromJson(int? json) { + if (json == null) { + return SKProductDiscountPaymentMode.payAsYouGo; + } + return _$enumDecode( + _$SKProductDiscountPaymentModeEnumMap + .cast(), + json); + } + + @override + int toJson(SKProductDiscountPaymentMode object) => + _$SKProductDiscountPaymentModeEnumMap[object]!; +} + +// Define a class so we generate serializer helper methods for the enums +@JsonSerializable() +class _SerializedEnums { + late SKPaymentTransactionStateWrapper response; + late SKSubscriptionPeriodUnit unit; + late SKProductDiscountPaymentMode discountPaymentMode; +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.g.dart new file mode 100644 index 000000000000..b003f435a800 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'enum_converters.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SerializedEnums _$_SerializedEnumsFromJson(Map json) { + return _SerializedEnums() + ..response = _$enumDecode( + _$SKPaymentTransactionStateWrapperEnumMap, json['response']) + ..unit = _$enumDecode(_$SKSubscriptionPeriodUnitEnumMap, json['unit']) + ..discountPaymentMode = _$enumDecode( + _$SKProductDiscountPaymentModeEnumMap, json['discountPaymentMode']); +} + +Map _$_SerializedEnumsToJson(_SerializedEnums instance) => + { + 'response': _$SKPaymentTransactionStateWrapperEnumMap[instance.response], + 'unit': _$SKSubscriptionPeriodUnitEnumMap[instance.unit], + 'discountPaymentMode': + _$SKProductDiscountPaymentModeEnumMap[instance.discountPaymentMode], + }; + +K _$enumDecode( + Map enumValues, + Object? source, { + K? unknownValue, +}) { + if (source == null) { + throw ArgumentError( + 'A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}', + ); + } + + return enumValues.entries.singleWhere( + (e) => e.value == source, + orElse: () { + if (unknownValue == null) { + throw ArgumentError( + '`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}', + ); + } + return MapEntry(unknownValue, enumValues.values.first); + }, + ).key; +} + +const _$SKPaymentTransactionStateWrapperEnumMap = { + SKPaymentTransactionStateWrapper.purchasing: 0, + SKPaymentTransactionStateWrapper.purchased: 1, + SKPaymentTransactionStateWrapper.failed: 2, + SKPaymentTransactionStateWrapper.restored: 3, + SKPaymentTransactionStateWrapper.deferred: 4, + SKPaymentTransactionStateWrapper.unspecified: -1, +}; + +const _$SKSubscriptionPeriodUnitEnumMap = { + SKSubscriptionPeriodUnit.day: 0, + SKSubscriptionPeriodUnit.week: 1, + SKSubscriptionPeriodUnit.month: 2, + SKSubscriptionPeriodUnit.year: 3, +}; + +const _$SKProductDiscountPaymentModeEnumMap = { + SKProductDiscountPaymentMode.payAsYouGo: 0, + SKProductDiscountPaymentMode.payUpFront: 1, + SKProductDiscountPaymentMode.freeTrail: 2, + SKProductDiscountPaymentMode.unspecified: -1, +}; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart new file mode 100644 index 000000000000..b677772869f6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart @@ -0,0 +1,385 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:ui' show hashValues; + +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:meta/meta.dart'; + +import '../channel.dart'; +import 'sk_payment_transaction_wrappers.dart'; +import 'sk_product_wrapper.dart'; + +part 'sk_payment_queue_wrapper.g.dart'; + +/// A wrapper around +/// [`SKPaymentQueue`](https://developer.apple.com/documentation/storekit/skpaymentqueue?language=objc). +/// +/// The payment queue contains payment related operations. It communicates with +/// the App Store and presents a user interface for the user to process and +/// authorize payments. +/// +/// Full information on using `SKPaymentQueue` and processing purchases is +/// available at the [In-App Purchase Programming +/// Guide](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Introduction.html#//apple_ref/doc/uid/TP40008267). +class SKPaymentQueueWrapper { + /// Returns the default payment queue. + /// + /// We do not support instantiating a custom payment queue, hence the + /// singleton. However, you can override the observer. + factory SKPaymentQueueWrapper() { + return _singleton; + } + + SKPaymentQueueWrapper._(); + + static final SKPaymentQueueWrapper _singleton = SKPaymentQueueWrapper._(); + + SKTransactionObserverWrapper? _observer; + + /// Calls [`-[SKPaymentQueue transactions]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506026-transactions?language=objc) + Future> transactions() async { + return _getTransactionList((await channel + .invokeListMethod('-[SKPaymentQueue transactions]'))!); + } + + /// Calls [`-[SKPaymentQueue canMakePayments:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506139-canmakepayments?language=objc). + static Future canMakePayments() async => + (await channel + .invokeMethod('-[SKPaymentQueue canMakePayments:]')) ?? + false; + + /// Sets an observer to listen to all incoming transaction events. + /// + /// This should be called and set as soon as the app launches in order to + /// avoid missing any purchase updates from the App Store. See the + /// documentation on StoreKit's [`-[SKPaymentQueue + /// addTransactionObserver:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506042-addtransactionobserver?language=objc). + void setTransactionObserver(SKTransactionObserverWrapper observer) { + _observer = observer; + channel.setMethodCallHandler(_handleObserverCallbacks); + } + + /// Posts a payment to the queue. + /// + /// This sends a purchase request to the App Store for confirmation. + /// Transaction updates will be delivered to the set + /// [SkTransactionObserverWrapper]. + /// + /// A couple preconditions need to be met before calling this method. + /// + /// - At least one [SKTransactionObserverWrapper] should have been added to + /// the payment queue using [addTransactionObserver]. + /// - The [payment.productIdentifier] needs to have been previously fetched + /// using [SKRequestMaker.startProductRequest] so that a valid `SKProduct` + /// has been cached in the platform side already. Because of this + /// [payment.productIdentifier] cannot be hardcoded. + /// + /// This method calls StoreKit's [`-[SKPaymentQueue addPayment:]`] + /// (https://developer.apple.com/documentation/storekit/skpaymentqueue/1506036-addpayment?preferredLanguage=occ). + /// + /// Also see [sandbox + /// testing](https://developer.apple.com/apple-pay/sandbox-testing/). + Future addPayment(SKPaymentWrapper payment) async { + assert(_observer != null, + '[in_app_purchase]: Trying to add a payment without an observer. One must be set using `SkPaymentQueueWrapper.setTransactionObserver` before the app launches.'); + final Map requestMap = payment.toMap(); + await channel.invokeMethod( + '-[InAppPurchasePlugin addPayment:result:]', + requestMap, + ); + } + + /// Finishes a transaction and removes it from the queue. + /// + /// This method should be called after the given [transaction] has been + /// succesfully processed and its content has been delivered to the user. + /// Transaction status updates are propagated to [SkTransactionObserver]. + /// + /// This will throw a Platform exception if [transaction.transactionState] is + /// [SKPaymentTransactionStateWrapper.purchasing]. + /// + /// This method calls StoreKit's [`-[SKPaymentQueue + /// finishTransaction:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506003-finishtransaction?language=objc). + Future finishTransaction( + SKPaymentTransactionWrapper transaction) async { + Map requestMap = transaction.toFinishMap(); + await channel.invokeMethod( + '-[InAppPurchasePlugin finishTransaction:result:]', + requestMap, + ); + } + + /// Restore previously purchased transactions. + /// + /// Use this to load previously purchased content on a new device. + /// + /// This call triggers purchase updates on the set + /// [SKTransactionObserverWrapper] for previously made transactions. This will + /// invoke [SKTransactionObserverWrapper.restoreCompletedTransactions], + /// [SKTransactionObserverWrapper.paymentQueueRestoreCompletedTransactionsFinished], + /// and [SKTransactionObserverWrapper.updatedTransaction]. These restored + /// transactions need to be marked complete with [finishTransaction] once the + /// content is delivered, like any other transaction. + /// + /// The `applicationUserName` should match the original + /// [SKPaymentWrapper.applicationUsername] used in [addPayment]. + /// If no `applicationUserName` was used, `applicationUserName` should be null. + /// + /// This method either triggers [`-[SKPayment + /// restoreCompletedTransactions]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506123-restorecompletedtransactions?language=objc) + /// or [`-[SKPayment restoreCompletedTransactionsWithApplicationUsername:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1505992-restorecompletedtransactionswith?language=objc) + /// depending on whether the `applicationUserName` is set. + Future restoreTransactions({String? applicationUserName}) async { + await channel.invokeMethod( + '-[InAppPurchasePlugin restoreTransactions:result:]', + applicationUserName); + } + + /// Present Code Redemption Sheet + /// + /// Use this to allow Users to enter and redeem Codes + /// + /// This method triggers [`-[SKPayment + /// presentCodeRedemptionSheet]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3566726-presentcoderedemptionsheet?language=objc) + Future presentCodeRedemptionSheet() async { + await channel.invokeMethod( + '-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]'); + } + + // Triage a method channel call from the platform and triggers the correct observer method. + Future _handleObserverCallbacks(MethodCall call) async { + assert(_observer != null, + '[in_app_purchase]: (Fatal)The observer has not been set but we received a purchase transaction notification. Please ensure the observer has been set using `setTransactionObserver`. Make sure the observer is added right at the App Launch.'); + final SKTransactionObserverWrapper observer = _observer!; + switch (call.method) { + case 'updatedTransactions': + { + final List transactions = + _getTransactionList(call.arguments); + return Future(() { + observer.updatedTransactions(transactions: transactions); + }); + } + case 'removedTransactions': + { + final List transactions = + _getTransactionList(call.arguments); + return Future(() { + observer.removedTransactions(transactions: transactions); + }); + } + case 'restoreCompletedTransactionsFailed': + { + SKError error = SKError.fromJson(call.arguments); + return Future(() { + observer.restoreCompletedTransactionsFailed(error: error); + }); + } + case 'paymentQueueRestoreCompletedTransactionsFinished': + { + return Future(() { + observer.paymentQueueRestoreCompletedTransactionsFinished(); + }); + } + case 'shouldAddStorePayment': + { + SKPaymentWrapper payment = + SKPaymentWrapper.fromJson(call.arguments['payment']); + SKProductWrapper product = + SKProductWrapper.fromJson(call.arguments['product']); + return Future(() { + if (observer.shouldAddStorePayment( + payment: payment, product: product) == + true) { + SKPaymentQueueWrapper().addPayment(payment); + } + }); + } + default: + break; + } + throw PlatformException( + code: 'no_such_callback', + message: 'Did not recognize the observer callback ${call.method}.'); + } + + // Get transaction wrapper object list from arguments. + List _getTransactionList( + List transactionsData) { + return transactionsData.map((dynamic map) { + return SKPaymentTransactionWrapper.fromJson( + Map.castFrom(map)); + }).toList(); + } +} + +/// Dart wrapper around StoreKit's +/// [NSError](https://developer.apple.com/documentation/foundation/nserror?language=objc). +@immutable +@JsonSerializable() +class SKError { + /// Creates a new [SKError] object with the provided information. + const SKError( + {required this.code, required this.domain, required this.userInfo}); + + /// Constructs an instance of this from a key-value map of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. The `map` parameter must not be + /// null. + factory SKError.fromJson(Map map) { + return _$SKErrorFromJson(map); + } + + /// Error [code](https://developer.apple.com/documentation/foundation/1448136-nserror_codes) + /// as defined in the Cocoa Framework. + @JsonKey(defaultValue: 0) + final int code; + + /// Error + /// [domain](https://developer.apple.com/documentation/foundation/nscocoaerrordomain?language=objc) + /// as defined in the Cocoa Framework. + @JsonKey(defaultValue: '') + final String domain; + + /// A map that contains more detailed information about the error. + /// + /// Any key of the map must be a valid [NSErrorUserInfoKey](https://developer.apple.com/documentation/foundation/nserroruserinfokey?language=objc). + @JsonKey(defaultValue: {}) + final Map userInfo; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKError typedOther = other as SKError; + return typedOther.code == code && + typedOther.domain == domain && + DeepCollectionEquality.unordered() + .equals(typedOther.userInfo, userInfo); + } + + @override + int get hashCode => hashValues( + code, + domain, + userInfo, + ); +} + +/// Dart wrapper around StoreKit's +/// [SKPayment](https://developer.apple.com/documentation/storekit/skpayment?language=objc). +/// +/// Used as the parameter to initiate a payment. In general, a developer should +/// not need to create the payment object explicitly; instead, use +/// [SKPaymentQueueWrapper.addPayment] directly with a product identifier to +/// initiate a payment. +@immutable +@JsonSerializable() +class SKPaymentWrapper { + /// Creates a new [SKPaymentWrapper] with the provided information. + const SKPaymentWrapper( + {required this.productIdentifier, + this.applicationUsername, + this.requestData, + this.quantity = 1, + this.simulatesAskToBuyInSandbox = false}); + + /// Constructs an instance of this from a key value map of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. The `map` parameter must not be + /// null. + factory SKPaymentWrapper.fromJson(Map map) { + assert(map != null); + return _$SKPaymentWrapperFromJson(map); + } + + /// Creates a Map object describes the payment object. + Map toMap() { + return { + 'productIdentifier': productIdentifier, + 'applicationUsername': applicationUsername, + 'requestData': requestData, + 'quantity': quantity, + 'simulatesAskToBuyInSandbox': simulatesAskToBuyInSandbox + }; + } + + /// The id for the product that the payment is for. + @JsonKey(defaultValue: '') + final String productIdentifier; + + /// An opaque id for the user's account. + /// + /// Used to help the store detect irregular activity. See + /// [applicationUsername](https://developer.apple.com/documentation/storekit/skpayment/1506116-applicationusername?language=objc) + /// for more details. For example, you can use a one-way hash of the user’s + /// account name on your server. Don’t use the Apple ID for your developer + /// account, the user’s Apple ID, or the user’s plaintext account name on + /// your server. + final String? applicationUsername; + + /// Reserved for future use. + /// + /// The value must be null before sending the payment. If the value is not + /// null, the payment will be rejected. + /// + // The iOS Platform provided this property but it is reserved for future use. + // We also provide this property to match the iOS platform. Converted to + // String from NSData from ios platform using UTF8Encoding. The / default is + // null. + final String? requestData; + + /// The amount of the product this payment is for. + /// + /// The default is 1. The minimum is 1. The maximum is 10. + /// + /// If the object is invalid, the value could be 0. + @JsonKey(defaultValue: 0) + final int quantity; + + /// Produces an "ask to buy" flow in the sandbox. + /// + /// Setting it to `true` will cause a transaction to be in the state [SKPaymentTransactionStateWrapper.deferred], + /// which produce an "ask to buy" prompt that interrupts the the payment flow. + /// + /// Default is `false`. + /// + /// See https://developer.apple.com/in-app-purchase/ for a guide on Sandbox + /// testing. + @JsonKey(defaultValue: false) + final bool simulatesAskToBuyInSandbox; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKPaymentWrapper typedOther = other as SKPaymentWrapper; + return typedOther.productIdentifier == productIdentifier && + typedOther.applicationUsername == applicationUsername && + typedOther.quantity == quantity && + typedOther.simulatesAskToBuyInSandbox == simulatesAskToBuyInSandbox && + typedOther.requestData == requestData; + } + + @override + int get hashCode => hashValues(productIdentifier, applicationUsername, + quantity, simulatesAskToBuyInSandbox, requestData); + + @override + String toString() => _$SKPaymentWrapperToJson(this).toString(); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart new file mode 100644 index 000000000000..2b886597adc5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sk_payment_queue_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SKError _$SKErrorFromJson(Map json) { + return SKError( + code: json['code'] as int? ?? 0, + domain: json['domain'] as String? ?? '', + userInfo: (json['userInfo'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + ) ?? + {}, + ); +} + +Map _$SKErrorToJson(SKError instance) => { + 'code': instance.code, + 'domain': instance.domain, + 'userInfo': instance.userInfo, + }; + +SKPaymentWrapper _$SKPaymentWrapperFromJson(Map json) { + return SKPaymentWrapper( + productIdentifier: json['productIdentifier'] as String? ?? '', + applicationUsername: json['applicationUsername'] as String?, + requestData: json['requestData'] as String?, + quantity: json['quantity'] as int? ?? 0, + simulatesAskToBuyInSandbox: + json['simulatesAskToBuyInSandbox'] as bool? ?? false, + ); +} + +Map _$SKPaymentWrapperToJson(SKPaymentWrapper instance) => + { + 'productIdentifier': instance.productIdentifier, + 'applicationUsername': instance.applicationUsername, + 'requestData': instance.requestData, + 'quantity': instance.quantity, + 'simulatesAskToBuyInSandbox': instance.simulatesAskToBuyInSandbox, + }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.dart new file mode 100644 index 000000000000..01cd6db0dda1 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.dart @@ -0,0 +1,203 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show hashValues; +import 'package:json_annotation/json_annotation.dart'; +import 'sk_product_wrapper.dart'; +import 'sk_payment_queue_wrapper.dart'; +import 'enum_converters.dart'; + +part 'sk_payment_transaction_wrappers.g.dart'; + +/// Callback handlers for transaction status changes. +/// +/// Must be subclassed. Must be instantiated and added to the +/// [SKPaymentQueueWrapper] via [SKPaymentQueueWrapper.setTransactionObserver] +/// at app launch. +/// +/// This class is a Dart wrapper around [SKTransactionObserver](https://developer.apple.com/documentation/storekit/skpaymenttransactionobserver?language=objc). +abstract class SKTransactionObserverWrapper { + /// Triggered when any transactions are updated. + void updatedTransactions( + {required List transactions}); + + /// Triggered when any transactions are removed from the payment queue. + void removedTransactions( + {required List transactions}); + + /// Triggered when there is an error while restoring transactions. + void restoreCompletedTransactionsFailed({required SKError error}); + + /// Triggered when payment queue has finished sending restored transactions. + void paymentQueueRestoreCompletedTransactionsFinished(); + + /// Triggered when a user initiates an in-app purchase from App Store. + /// + /// Return `true` to continue the transaction in your app. If you have + /// multiple [SKTransactionObserverWrapper]s, the transaction will continue if + /// any [SKTransactionObserverWrapper] returns `true`. Return `false` to defer + /// or cancel the transaction. For example, you may need to defer a + /// transaction if the user is in the middle of onboarding. You can also + /// continue the transaction later by calling [addPayment] with the + /// `payment` param from this method. + bool shouldAddStorePayment( + {required SKPaymentWrapper payment, required SKProductWrapper product}); +} + +/// The state of a transaction. +/// +/// Dart wrapper around StoreKit's +/// [SKPaymentTransactionState](https://developer.apple.com/documentation/storekit/skpaymenttransactionstate?language=objc). +enum SKPaymentTransactionStateWrapper { + /// Indicates the transaction is being processed in App Store. + /// + /// You should update your UI to indicate that you are waiting for the + /// transaction to update to another state. Never complete a transaction that + /// is still in a purchasing state. + @JsonValue(0) + purchasing, + + /// The user's payment has been succesfully processed. + /// + /// You should provide the user the content that they purchased. + @JsonValue(1) + purchased, + + /// The transaction failed. + /// + /// Check the [SKPaymentTransactionWrapper.error] property from + /// [SKPaymentTransactionWrapper] for details. + @JsonValue(2) + failed, + + /// This transaction is restoring content previously purchased by the user. + /// + /// The previous transaction information can be obtained in + /// [SKPaymentTransactionWrapper.originalTransaction] from + /// [SKPaymentTransactionWrapper]. + @JsonValue(3) + restored, + + /// The transaction is in the queue but pending external action. Wait for + /// another callback to get the final state. + /// + /// You should update your UI to indicate that you are waiting for the + /// transaction to update to another state. + @JsonValue(4) + deferred, + + /// Indicates the transaction is in an unspecified state. + @JsonValue(-1) + unspecified, +} + +/// Created when a payment is added to the [SKPaymentQueueWrapper]. +/// +/// Transactions are delivered to your app when a payment is finished +/// processing. Completed transactions provide a receipt and a transaction +/// identifier that the app can use to save a permanent record of the processed +/// payment. +/// +/// Dart wrapper around StoreKit's +/// [SKPaymentTransaction](https://developer.apple.com/documentation/storekit/skpaymenttransaction?language=objc). +@JsonSerializable() +class SKPaymentTransactionWrapper { + /// Creates a new [SKPaymentTransactionWrapper] with the provided information. + SKPaymentTransactionWrapper({ + required this.payment, + required this.transactionState, + this.originalTransaction, + this.transactionTimeStamp, + this.transactionIdentifier, + this.error, + }); + + /// Constructs an instance of this from a key value map of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. The `map` parameter must not be + /// null. + factory SKPaymentTransactionWrapper.fromJson(Map map) { + return _$SKPaymentTransactionWrapperFromJson(map); + } + + /// Current transaction state. + @SKTransactionStatusConverter() + final SKPaymentTransactionStateWrapper transactionState; + + /// The payment that has been created and added to the payment queue which + /// generated this transaction. + final SKPaymentWrapper payment; + + /// The original Transaction. + /// + /// Only available if the [transactionState] is [SKPaymentTransactionStateWrapper.restored]. + /// Otherwise the value is `null`. + /// + /// When the [transactionState] + /// is [SKPaymentTransactionStateWrapper.restored], the current transaction + /// object holds a new [transactionIdentifier]. + final SKPaymentTransactionWrapper? originalTransaction; + + /// The timestamp of the transaction. + /// + /// Seconds since epoch. It is only defined when the [transactionState] is + /// [SKPaymentTransactionStateWrapper.purchased] or + /// [SKPaymentTransactionStateWrapper.restored]. + /// Otherwise, the value is `null`. + final double? transactionTimeStamp; + + /// The unique string identifer of the transaction. + /// + /// It is only defined when the [transactionState] is + /// [SKPaymentTransactionStateWrapper.purchased] or + /// [SKPaymentTransactionStateWrapper.restored]. You may wish to record this + /// string as part of an audit trail for App Store purchases. The value of + /// this string corresponds to the same property in the receipt. + /// + /// The value is `null` if it is an unsuccessful transaction. + final String? transactionIdentifier; + + /// The error object + /// + /// Only available if the [transactionState] is + /// [SKPaymentTransactionStateWrapper.failed]. + final SKError? error; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKPaymentTransactionWrapper typedOther = + other as SKPaymentTransactionWrapper; + return typedOther.payment == payment && + typedOther.transactionState == transactionState && + typedOther.originalTransaction == originalTransaction && + typedOther.transactionTimeStamp == transactionTimeStamp && + typedOther.transactionIdentifier == transactionIdentifier && + typedOther.error == error; + } + + @override + int get hashCode => hashValues( + this.payment, + this.transactionState, + this.originalTransaction, + this.transactionTimeStamp, + this.transactionIdentifier, + this.error); + + @override + String toString() => _$SKPaymentTransactionWrapperToJson(this).toString(); + + /// The payload that is used to finish this transaction. + Map toFinishMap() => { + "transactionIdentifier": this.transactionIdentifier, + "productIdentifier": this.payment.productIdentifier, + }; +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart new file mode 100644 index 000000000000..4c7af21bc151 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sk_payment_transaction_wrappers.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SKPaymentTransactionWrapper _$SKPaymentTransactionWrapperFromJson(Map json) { + return SKPaymentTransactionWrapper( + payment: SKPaymentWrapper.fromJson( + Map.from(json['payment'] as Map)), + transactionState: const SKTransactionStatusConverter() + .fromJson(json['transactionState'] as int?), + originalTransaction: json['originalTransaction'] == null + ? null + : SKPaymentTransactionWrapper.fromJson( + Map.from(json['originalTransaction'] as Map)), + transactionTimeStamp: (json['transactionTimeStamp'] as num?)?.toDouble(), + transactionIdentifier: json['transactionIdentifier'] as String?, + error: json['error'] == null + ? null + : SKError.fromJson(Map.from(json['error'] as Map)), + ); +} + +Map _$SKPaymentTransactionWrapperToJson( + SKPaymentTransactionWrapper instance) => + { + 'transactionState': const SKTransactionStatusConverter() + .toJson(instance.transactionState), + 'payment': instance.payment, + 'originalTransaction': instance.originalTransaction, + 'transactionTimeStamp': instance.transactionTimeStamp, + 'transactionIdentifier': instance.transactionIdentifier, + 'error': instance.error, + }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart new file mode 100644 index 000000000000..ef0e6671d177 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart @@ -0,0 +1,374 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show hashValues; +import 'package:collection/collection.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'enum_converters.dart'; + +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'sk_product_wrapper.g.dart'; + +/// Dart wrapper around StoreKit's [SKProductsResponse](https://developer.apple.com/documentation/storekit/skproductsresponse?language=objc). +/// +/// Represents the response object returned by [SKRequestMaker.startProductRequest]. +/// Contains information about a list of products and a list of invalid product identifiers. +@JsonSerializable() +class SkProductResponseWrapper { + /// Creates an [SkProductResponseWrapper] with the given product details. + SkProductResponseWrapper( + {required this.products, required this.invalidProductIdentifiers}); + + /// Constructing an instance from a map from the Objective-C layer. + /// + /// This method should only be used with `map` values returned by [SKRequestMaker.startProductRequest]. + factory SkProductResponseWrapper.fromJson(Map map) { + return _$SkProductResponseWrapperFromJson(map); + } + + /// Stores all matching successfully found products. + /// + /// One product in this list matches one valid product identifier passed to the [SKRequestMaker.startProductRequest]. + /// Will be empty if the [SKRequestMaker.startProductRequest] method does not pass any correct product identifier. + @JsonKey(defaultValue: []) + final List products; + + /// Stores product identifiers in the `productIdentifiers` from [SKRequestMaker.startProductRequest] that are not recognized by the App Store. + /// + /// The App Store will not recognize a product identifier unless certain criteria are met. A detailed list of the criteria can be + /// found here https://developer.apple.com/documentation/storekit/skproductsresponse/1505985-invalidproductidentifiers?language=objc. + /// Will be empty if all the product identifiers are valid. + @JsonKey(defaultValue: []) + final List invalidProductIdentifiers; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SkProductResponseWrapper typedOther = + other as SkProductResponseWrapper; + return DeepCollectionEquality().equals(typedOther.products, products) && + DeepCollectionEquality().equals( + typedOther.invalidProductIdentifiers, invalidProductIdentifiers); + } + + @override + int get hashCode => hashValues(this.products, this.invalidProductIdentifiers); +} + +/// Dart wrapper around StoreKit's [SKProductPeriodUnit](https://developer.apple.com/documentation/storekit/skproductperiodunit?language=objc). +/// +/// Used as a property in the [SKProductSubscriptionPeriodWrapper]. Minimum is a day and maximum is a year. +// The values of the enum options are matching the [SKProductPeriodUnit]'s values. Should there be an update or addition +// in the [SKProductPeriodUnit], this need to be updated to match. +enum SKSubscriptionPeriodUnit { + /// An interval lasting one day. + @JsonValue(0) + day, + + /// An interval lasting one month. + @JsonValue(1) + + /// An interval lasting one week. + week, + @JsonValue(2) + + /// An interval lasting one month. + month, + + /// An interval lasting one year. + @JsonValue(3) + year, +} + +/// Dart wrapper around StoreKit's [SKProductSubscriptionPeriod](https://developer.apple.com/documentation/storekit/skproductsubscriptionperiod?language=objc). +/// +/// A period is defined by a [numberOfUnits] and a [unit], e.g for a 3 months period [numberOfUnits] is 3 and [unit] is a month. +/// It is used as a property in [SKProductDiscountWrapper] and [SKProductWrapper]. +@JsonSerializable() +class SKProductSubscriptionPeriodWrapper { + /// Creates an [SKProductSubscriptionPeriodWrapper] for a `numberOfUnits`x`unit` period. + SKProductSubscriptionPeriodWrapper( + {required this.numberOfUnits, required this.unit}); + + /// Constructing an instance from a map from the Objective-C layer. + /// + /// This method should only be used with `map` values returned by [SKProductDiscountWrapper.fromJson] or [SKProductWrapper.fromJson]. + factory SKProductSubscriptionPeriodWrapper.fromJson( + Map? map) { + if (map == null) { + return SKProductSubscriptionPeriodWrapper( + numberOfUnits: 0, unit: SKSubscriptionPeriodUnit.day); + } + return _$SKProductSubscriptionPeriodWrapperFromJson(map); + } + + /// The number of [unit] units in this period. + /// + /// Must be greater than 0 if the object is valid. + @JsonKey(defaultValue: 0) + final int numberOfUnits; + + /// The time unit used to specify the length of this period. + @SKSubscriptionPeriodUnitConverter() + final SKSubscriptionPeriodUnit unit; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKProductSubscriptionPeriodWrapper typedOther = + other as SKProductSubscriptionPeriodWrapper; + return typedOther.numberOfUnits == numberOfUnits && typedOther.unit == unit; + } + + @override + int get hashCode => hashValues(this.numberOfUnits, this.unit); +} + +/// Dart wrapper around StoreKit's [SKProductDiscountPaymentMode](https://developer.apple.com/documentation/storekit/skproductdiscountpaymentmode?language=objc). +/// +/// This is used as a property in the [SKProductDiscountWrapper]. +// The values of the enum options are matching the [SKProductDiscountPaymentMode]'s values. Should there be an update or addition +// in the [SKProductDiscountPaymentMode], this need to be updated to match. +enum SKProductDiscountPaymentMode { + /// Allows user to pay the discounted price at each payment period. + @JsonValue(0) + payAsYouGo, + + /// Allows user to pay the discounted price upfront and receive the product for the rest of time that was paid for. + @JsonValue(1) + payUpFront, + + /// User pays nothing during the discounted period. + @JsonValue(2) + freeTrail, + + /// Unspecified mode. + @JsonValue(-1) + unspecified, +} + +/// Dart wrapper around StoreKit's [SKProductDiscount](https://developer.apple.com/documentation/storekit/skproductdiscount?language=objc). +/// +/// It is used as a property in [SKProductWrapper]. +@JsonSerializable() +class SKProductDiscountWrapper { + /// Creates an [SKProductDiscountWrapper] with the given discount details. + SKProductDiscountWrapper( + {required this.price, + required this.priceLocale, + required this.numberOfPeriods, + required this.paymentMode, + required this.subscriptionPeriod}); + + /// Constructing an instance from a map from the Objective-C layer. + /// + /// This method should only be used with `map` values returned by [SKProductWrapper.fromJson]. + factory SKProductDiscountWrapper.fromJson(Map map) { + return _$SKProductDiscountWrapperFromJson(map); + } + + /// The discounted price, in the currency that is defined in [priceLocale]. + @JsonKey(defaultValue: '') + final String price; + + /// Includes locale information about the price, e.g. `$` as the currency symbol for US locale. + final SKPriceLocaleWrapper priceLocale; + + /// The object represent the discount period length. + /// + /// The value must be >= 0 if the object is valid. + @JsonKey(defaultValue: 0) + final int numberOfPeriods; + + /// The object indicates how the discount price is charged. + @SKProductDiscountPaymentModeConverter() + final SKProductDiscountPaymentMode paymentMode; + + /// The object represents the duration of single subscription period for the discount. + /// + /// The [subscriptionPeriod] of the discount is independent of the product's [subscriptionPeriod], + /// and their units and duration do not have to be matched. + final SKProductSubscriptionPeriodWrapper subscriptionPeriod; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKProductDiscountWrapper typedOther = + other as SKProductDiscountWrapper; + return typedOther.price == price && + typedOther.priceLocale == priceLocale && + typedOther.numberOfPeriods == numberOfPeriods && + typedOther.paymentMode == paymentMode && + typedOther.subscriptionPeriod == subscriptionPeriod; + } + + @override + int get hashCode => hashValues(this.price, this.priceLocale, + this.numberOfPeriods, this.paymentMode, this.subscriptionPeriod); +} + +/// Dart wrapper around StoreKit's [SKProduct](https://developer.apple.com/documentation/storekit/skproduct?language=objc). +/// +/// A list of [SKProductWrapper] is returned in the [SKRequestMaker.startProductRequest] method, and +/// should be stored for use when making a payment. +@JsonSerializable() +class SKProductWrapper { + /// Creates an [SKProductWrapper] with the given product details. + SKProductWrapper({ + required this.productIdentifier, + required this.localizedTitle, + required this.localizedDescription, + required this.priceLocale, + this.subscriptionGroupIdentifier, + required this.price, + this.subscriptionPeriod, + this.introductoryPrice, + }); + + /// Constructing an instance from a map from the Objective-C layer. + /// + /// This method should only be used with `map` values returned by [SkProductResponseWrapper.fromJson]. + factory SKProductWrapper.fromJson(Map map) { + return _$SKProductWrapperFromJson(map); + } + + /// The unique identifier of the product. + @JsonKey(defaultValue: '') + final String productIdentifier; + + /// The localizedTitle of the product. + /// + /// It is localized based on the current locale. + @JsonKey(defaultValue: '') + final String localizedTitle; + + /// The localized description of the product. + /// + /// It is localized based on the current locale. + @JsonKey(defaultValue: '') + final String localizedDescription; + + /// Includes locale information about the price, e.g. `$` as the currency symbol for US locale. + final SKPriceLocaleWrapper priceLocale; + + /// The subscription group identifier. + /// + /// If the product is not a subscription, the value is `null`. + /// + /// A subscription group is a collection of subscription products. + /// Check [SubscriptionGroup](https://developer.apple.com/app-store/subscriptions/) for more details about subscription group. + final String? subscriptionGroupIdentifier; + + /// The price of the product, in the currency that is defined in [priceLocale]. + @JsonKey(defaultValue: '') + final String price; + + /// The object represents the subscription period of the product. + /// + /// Can be [null] is the product is not a subscription. + final SKProductSubscriptionPeriodWrapper? subscriptionPeriod; + + /// The object represents the duration of single subscription period. + /// + /// This is only available if you set up the introductory price in the App Store Connect, otherwise the value is `null`. + /// Programmer is also responsible to determine if the user is eligible to receive it. See https://developer.apple.com/documentation/storekit/in-app_purchase/offering_introductory_pricing_in_your_app?language=objc + /// for more details. + /// The [subscriptionPeriod] of the discount is independent of the product's [subscriptionPeriod], + /// and their units and duration do not have to be matched. + final SKProductDiscountWrapper? introductoryPrice; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKProductWrapper typedOther = other as SKProductWrapper; + return typedOther.productIdentifier == productIdentifier && + typedOther.localizedTitle == localizedTitle && + typedOther.localizedDescription == localizedDescription && + typedOther.priceLocale == priceLocale && + typedOther.subscriptionGroupIdentifier == subscriptionGroupIdentifier && + typedOther.price == price && + typedOther.subscriptionPeriod == subscriptionPeriod && + typedOther.introductoryPrice == introductoryPrice; + } + + @override + int get hashCode => hashValues( + this.productIdentifier, + this.localizedTitle, + this.localizedDescription, + this.priceLocale, + this.subscriptionGroupIdentifier, + this.price, + this.subscriptionPeriod, + this.introductoryPrice); +} + +/// Object that indicates the locale of the price +/// +/// It is a thin wrapper of [NSLocale](https://developer.apple.com/documentation/foundation/nslocale?language=objc). +// TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this expanded. +// Matching android to only get the currencySymbol for now. +// https://github.com/flutter/flutter/issues/26610 +@JsonSerializable() +class SKPriceLocaleWrapper { + /// Creates a new price locale for `currencySymbol` and `currencyCode`. + SKPriceLocaleWrapper( + {required this.currencySymbol, required this.currencyCode}); + + /// Constructing an instance from a map from the Objective-C layer. + /// + /// This method should only be used with `map` values returned by [SKProductWrapper.fromJson] and [SKProductDiscountWrapper.fromJson]. + factory SKPriceLocaleWrapper.fromJson(Map? map) { + if (map == null) { + return SKPriceLocaleWrapper(currencyCode: '', currencySymbol: ''); + } + return _$SKPriceLocaleWrapperFromJson(map); + } + + ///The currency symbol for the locale, e.g. $ for US locale. + @JsonKey(defaultValue: '') + final String currencySymbol; + + ///The currency code for the locale, e.g. USD for US locale. + @JsonKey(defaultValue: '') + final String currencyCode; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKPriceLocaleWrapper typedOther = other as SKPriceLocaleWrapper; + return typedOther.currencySymbol == currencySymbol && + typedOther.currencyCode == currencyCode; + } + + @override + int get hashCode => hashValues(this.currencySymbol, this.currencyCode); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart new file mode 100644 index 000000000000..8c2eed3d6070 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart @@ -0,0 +1,123 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sk_product_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SkProductResponseWrapper _$SkProductResponseWrapperFromJson(Map json) { + return SkProductResponseWrapper( + products: (json['products'] as List?) + ?.map((e) => + SKProductWrapper.fromJson(Map.from(e as Map))) + .toList() ?? + [], + invalidProductIdentifiers: + (json['invalidProductIdentifiers'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + ); +} + +Map _$SkProductResponseWrapperToJson( + SkProductResponseWrapper instance) => + { + 'products': instance.products, + 'invalidProductIdentifiers': instance.invalidProductIdentifiers, + }; + +SKProductSubscriptionPeriodWrapper _$SKProductSubscriptionPeriodWrapperFromJson( + Map json) { + return SKProductSubscriptionPeriodWrapper( + numberOfUnits: json['numberOfUnits'] as int? ?? 0, + unit: const SKSubscriptionPeriodUnitConverter() + .fromJson(json['unit'] as int?), + ); +} + +Map _$SKProductSubscriptionPeriodWrapperToJson( + SKProductSubscriptionPeriodWrapper instance) => + { + 'numberOfUnits': instance.numberOfUnits, + 'unit': const SKSubscriptionPeriodUnitConverter().toJson(instance.unit), + }; + +SKProductDiscountWrapper _$SKProductDiscountWrapperFromJson(Map json) { + return SKProductDiscountWrapper( + price: json['price'] as String? ?? '', + priceLocale: + SKPriceLocaleWrapper.fromJson((json['priceLocale'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + numberOfPeriods: json['numberOfPeriods'] as int? ?? 0, + paymentMode: const SKProductDiscountPaymentModeConverter() + .fromJson(json['paymentMode'] as int?), + subscriptionPeriod: SKProductSubscriptionPeriodWrapper.fromJson( + (json['subscriptionPeriod'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + ); +} + +Map _$SKProductDiscountWrapperToJson( + SKProductDiscountWrapper instance) => + { + 'price': instance.price, + 'priceLocale': instance.priceLocale, + 'numberOfPeriods': instance.numberOfPeriods, + 'paymentMode': const SKProductDiscountPaymentModeConverter() + .toJson(instance.paymentMode), + 'subscriptionPeriod': instance.subscriptionPeriod, + }; + +SKProductWrapper _$SKProductWrapperFromJson(Map json) { + return SKProductWrapper( + productIdentifier: json['productIdentifier'] as String? ?? '', + localizedTitle: json['localizedTitle'] as String? ?? '', + localizedDescription: json['localizedDescription'] as String? ?? '', + priceLocale: + SKPriceLocaleWrapper.fromJson((json['priceLocale'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + subscriptionGroupIdentifier: json['subscriptionGroupIdentifier'] as String?, + price: json['price'] as String? ?? '', + subscriptionPeriod: json['subscriptionPeriod'] == null + ? null + : SKProductSubscriptionPeriodWrapper.fromJson( + (json['subscriptionPeriod'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + introductoryPrice: json['introductoryPrice'] == null + ? null + : SKProductDiscountWrapper.fromJson( + Map.from(json['introductoryPrice'] as Map)), + ); +} + +Map _$SKProductWrapperToJson(SKProductWrapper instance) => + { + 'productIdentifier': instance.productIdentifier, + 'localizedTitle': instance.localizedTitle, + 'localizedDescription': instance.localizedDescription, + 'priceLocale': instance.priceLocale, + 'subscriptionGroupIdentifier': instance.subscriptionGroupIdentifier, + 'price': instance.price, + 'subscriptionPeriod': instance.subscriptionPeriod, + 'introductoryPrice': instance.introductoryPrice, + }; + +SKPriceLocaleWrapper _$SKPriceLocaleWrapperFromJson(Map json) { + return SKPriceLocaleWrapper( + currencySymbol: json['currencySymbol'] as String? ?? '', + currencyCode: json['currencyCode'] as String? ?? '', + ); +} + +Map _$SKPriceLocaleWrapperToJson( + SKPriceLocaleWrapper instance) => + { + 'currencySymbol': instance.currencySymbol, + 'currencyCode': instance.currencyCode, + }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_receipt_manager.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_receipt_manager.dart new file mode 100644 index 000000000000..3eb41cb66a14 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_receipt_manager.dart @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import '../channel.dart'; + +///This class contains static methods to manage StoreKit receipts. +class SKReceiptManager { + /// Retrieve the receipt data from your application's main bundle. + /// + /// The receipt data will be based64 encoded. The structure of the payload is defined using ASN.1. + /// You can use the receipt data retrieved by this method to validate users' purchases. + /// There are 2 ways to do so. Either validate locally or validate with App Store. + /// For more details on how to validate the receipt data, you can refer to Apple's document about [`About Receipt Validation`](https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Introduction.html#//apple_ref/doc/uid/TP40010573-CH105-SW1). + /// If the receipt is invalid or missing, you can use [SKRequestMaker.startRefreshReceiptRequest] to request a new receipt. + static Future retrieveReceiptData() async { + return (await channel.invokeMethod( + '-[InAppPurchasePlugin retrieveReceiptData:result:]')) ?? + ''; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_request_maker.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_request_maker.dart new file mode 100644 index 000000000000..d59f66fce2c9 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_request_maker.dart @@ -0,0 +1,59 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../channel.dart'; +import 'sk_product_wrapper.dart'; + +/// A request maker that handles all the requests made by SKRequest subclasses. +/// +/// There are multiple [SKRequest](https://developer.apple.com/documentation/storekit/skrequest?language=objc) subclasses handling different requests in the `StoreKit` with multiple delegate methods, +/// we consolidated all the `SKRequest` subclasses into this class to make requests in a more straightforward way. +/// The request maker will create a SKRequest object, immediately starting it, and completing the future successfully or throw an exception depending on what happened to the request. +class SKRequestMaker { + /// Fetches product information for a list of given product identifiers. + /// + /// The `productIdentifiers` should contain legitimate product identifiers that you declared for the products in the iTunes Connect. Invalid identifiers + /// will be stored and returned in [SkProductResponseWrapper.invalidProductIdentifiers]. Duplicate values in `productIdentifiers` will be omitted. + /// If `productIdentifiers` is null, an `storekit_invalid_argument` error will be returned. If `productIdentifiers` is empty, a [SkProductResponseWrapper] + /// will still be returned with [SkProductResponseWrapper.products] being null. + /// + /// [SkProductResponseWrapper] is returned if there is no error during the request. + /// A [PlatformException] is thrown if the platform code making the request fails. + Future startProductRequest( + List productIdentifiers) async { + final Map? productResponseMap = + await channel.invokeMapMethod( + '-[InAppPurchasePlugin startProductRequest:result:]', + productIdentifiers, + ); + if (productResponseMap == null) { + throw PlatformException( + code: 'storekit_no_response', + message: 'StoreKit: Failed to get response from platform.', + ); + } + return SkProductResponseWrapper.fromJson(productResponseMap); + } + + /// Uses [SKReceiptRefreshRequest](https://developer.apple.com/documentation/storekit/skreceiptrefreshrequest?language=objc) to request a new receipt. + /// + /// If the receipt is invalid or missing, you can use this API to request a new receipt. + /// The [receiptProperties] is optional and it exists only for [sandbox testing](https://developer.apple.com/apple-pay/sandbox-testing/). In the production app, call this API without pass in the [receiptProperties] parameter. + /// To test in the sandbox, you can request a receipt with any combination of properties to test the state transitions related to [`Volume Purchase Plan`](https://www.apple.com/business/site/docs/VPP_Business_Guide.pdf) receipts. + /// The valid keys in the receiptProperties are below (All of them are of type bool): + /// * isExpired: whether the receipt is expired. + /// * isRevoked: whether the receipt has been revoked. + /// * isVolumePurchase: whether the receipt is a Volume Purchase Plan receipt. + Future startRefreshReceiptRequest( + {Map? receiptProperties}) { + return channel.invokeMethod( + '-[InAppPurchasePlugin refreshReceipt:result:]', + receiptProperties, + ); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_product_details.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_product_details.dart new file mode 100644 index 000000000000..96386c5ef5ad --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_product_details.dart @@ -0,0 +1,47 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../store_kit_wrappers.dart'; + +/// The class represents the information of a product as registered in the Apple +/// AppStore. +class AppStoreProductDetails extends ProductDetails { + /// Creates a new AppStore specific product details object with the provided + /// details. + AppStoreProductDetails({ + required String id, + required String title, + required String description, + required String price, + required double rawPrice, + required String currencyCode, + required this.skProduct, + }) : super( + id: id, + title: title, + description: description, + price: price, + rawPrice: rawPrice, + currencyCode: currencyCode, + ); + + /// Points back to the [SKProductWrapper] object that was used to generate + /// this [AppStoreProductDetails] object. + final SKProductWrapper skProduct; + + /// Generate a [AppStoreProductDetails] object based on an iOS [SKProductWrapper] object. + factory AppStoreProductDetails.fromSKProduct(SKProductWrapper product) { + return AppStoreProductDetails( + id: product.productIdentifier, + title: product.localizedTitle, + description: product.localizedDescription, + price: product.priceLocale.currencySymbol + product.price, + rawPrice: double.parse(product.price), + currencyCode: product.priceLocale.currencyCode, + skProduct: product, + ); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_details.dart new file mode 100644 index 000000000000..6d6f241d6ca8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_details.dart @@ -0,0 +1,80 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../in_app_purchase_ios.dart'; +import '../../store_kit_wrappers.dart'; +import '../store_kit_wrappers/enum_converters.dart'; + +/// The class represents the information of a purchase made with the Apple +/// AppStore. +class AppStorePurchaseDetails extends PurchaseDetails { + /// Creates a new AppStore specific purchase details object with the provided + /// details. + AppStorePurchaseDetails( + {String? purchaseID, + required String productID, + required PurchaseVerificationData verificationData, + required String? transactionDate, + required this.skPaymentTransaction, + required PurchaseStatus status}) + : super( + productID: productID, + purchaseID: purchaseID, + transactionDate: transactionDate, + verificationData: verificationData, + status: status) { + this.status = status; + } + + /// Points back to the [SKPaymentTransactionWrapper] which was used to + /// generate this [AppStorePurchaseDetails] object. + final SKPaymentTransactionWrapper skPaymentTransaction; + + late PurchaseStatus _status; + + /// The status that this [PurchaseDetails] is currently on. + PurchaseStatus get status => _status; + set status(PurchaseStatus status) { + _pendingCompletePurchase = status != PurchaseStatus.pending; + _status = status; + } + + bool _pendingCompletePurchase = false; + bool get pendingCompletePurchase => _pendingCompletePurchase; + + /// Generate a [AppStorePurchaseDetails] object based on an iOS + /// [SKPaymentTransactionWrapper] object. + factory AppStorePurchaseDetails.fromSKTransaction( + SKPaymentTransactionWrapper transaction, + String base64EncodedReceipt, + ) { + final AppStorePurchaseDetails purchaseDetails = AppStorePurchaseDetails( + productID: transaction.payment.productIdentifier, + purchaseID: transaction.transactionIdentifier, + skPaymentTransaction: transaction, + status: SKTransactionStatusConverter() + .toPurchaseStatus(transaction.transactionState), + transactionDate: transaction.transactionTimeStamp != null + ? (transaction.transactionTimeStamp! * 1000).toInt().toString() + : null, + verificationData: PurchaseVerificationData( + localVerificationData: base64EncodedReceipt, + serverVerificationData: base64EncodedReceipt, + source: kIAPSource), + ); + + if (purchaseDetails.status == PurchaseStatus.error) { + purchaseDetails.error = IAPError( + source: kIAPSource, + code: kPurchaseErrorCode, + message: transaction.error?.domain ?? '', + details: transaction.error?.userInfo, + ); + } + + return purchaseDetails; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_param.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_param.dart new file mode 100644 index 000000000000..b2d8eea9d791 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_param.dart @@ -0,0 +1,31 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../store_kit_wrappers.dart'; + +/// Apple AppStore specific parameter object for generating a purchase. +class AppStorePurchaseParam extends PurchaseParam { + /// Creates a new [AppStorePurchaseParam] object with the given data. + AppStorePurchaseParam({ + required ProductDetails productDetails, + String? applicationUserName, + this.simulatesAskToBuyInSandbox = false, + }) : super( + productDetails: productDetails, + applicationUserName: applicationUserName, + ); + + /// Set it to `true` to produce an "ask to buy" flow for this payment in the + /// sandbox. + /// + /// If you want to test [simulatesAskToBuyInSandbox], you should ensure that + /// you create an instance of the [AppStorePurchaseParam] class and set its + /// [simulateAskToBuyInSandbox] field to `true` and use it with the + /// `buyNonConsumable` or `buyConsumable` methods. + /// + /// See also [SKPaymentWrapper.simulatesAskToBuyInSandbox]. + final bool simulatesAskToBuyInSandbox; +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/types.dart new file mode 100644 index 000000000000..a21bd4b5fbb1 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/types.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +export 'app_store_product_details.dart'; +export 'app_store_purchase_details.dart'; +export 'app_store_purchase_param.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart new file mode 100644 index 000000000000..b687d238083c --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart @@ -0,0 +1,9 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/store_kit_wrappers/sk_payment_queue_wrapper.dart'; +export 'src/store_kit_wrappers/sk_payment_transaction_wrappers.dart'; +export 'src/store_kit_wrappers/sk_product_wrapper.dart'; +export 'src/store_kit_wrappers/sk_receipt_manager.dart'; +export 'src/store_kit_wrappers/sk_request_maker.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml new file mode 100644 index 000000000000..a5351530f019 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -0,0 +1,34 @@ +name: in_app_purchase_ios +description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. +repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios +version: 0.1.0 + +# TODO(mvanbeusekom): Remove when in_app_purchase_platform_interface is published +publish_to: 'none' + +flutter: + plugin: + platforms: + ios: + pluginClass: InAppPurchasePlugin + +dependencies: + # TODO(mvanbeusekom): Replace with pub.dev version when in_app_purchase_platform_interface is published + in_app_purchase_platform_interface: + path: ../in_app_purchase_platform_interface + + flutter: + sdk: flutter + + meta: ^1.3.0 + test: ^1.16.0 + +dev_dependencies: + build_runner: ^1.11.1 + json_serializable: ^4.1.1 + flutter_test: + sdk: flutter + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart new file mode 100644 index 000000000000..f39241318670 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart @@ -0,0 +1,182 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_ios/src/channel.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +import '../store_kit_wrappers/sk_test_stub_objects.dart'; + +class FakeIOSPlatform { + FakeIOSPlatform() { + channel.setMockMethodCallHandler(onMethodCall); + } + + // pre-configured store informations + String? receiptData; + late Set validProductIDs; + late Map validProducts; + late List transactions; + late List finishedTransactions; + late bool testRestoredTransactionsNull; + late bool testTransactionFail; + PlatformException? queryProductException; + PlatformException? restoreException; + SKError? testRestoredError; + + void reset() { + transactions = []; + receiptData = 'dummy base64data'; + validProductIDs = ['123', '456'].toSet(); + validProducts = Map(); + for (String validID in validProductIDs) { + Map productWrapperMap = + buildProductMap(dummyProductWrapper); + productWrapperMap['productIdentifier'] = validID; + validProducts[validID] = SKProductWrapper.fromJson(productWrapperMap); + } + + SKPaymentTransactionWrapper tran1 = SKPaymentTransactionWrapper( + transactionIdentifier: '123', + payment: dummyPayment, + originalTransaction: dummyTransaction, + transactionTimeStamp: 123123123.022, + transactionState: SKPaymentTransactionStateWrapper.restored, + error: null, + ); + SKPaymentTransactionWrapper tran2 = SKPaymentTransactionWrapper( + transactionIdentifier: '1234', + payment: dummyPayment, + originalTransaction: dummyTransaction, + transactionTimeStamp: 123123123.022, + transactionState: SKPaymentTransactionStateWrapper.restored, + error: null, + ); + + transactions.addAll([tran1, tran2]); + finishedTransactions = []; + testRestoredTransactionsNull = false; + testTransactionFail = false; + queryProductException = null; + restoreException = null; + testRestoredError = null; + } + + SKPaymentTransactionWrapper createPendingTransaction(String id) { + return SKPaymentTransactionWrapper( + transactionIdentifier: '', + payment: SKPaymentWrapper(productIdentifier: id), + transactionState: SKPaymentTransactionStateWrapper.purchasing, + transactionTimeStamp: 123123.121, + error: null, + originalTransaction: null); + } + + SKPaymentTransactionWrapper createPurchasedTransaction( + String productId, String transactionId) { + return SKPaymentTransactionWrapper( + payment: SKPaymentWrapper(productIdentifier: productId), + transactionState: SKPaymentTransactionStateWrapper.purchased, + transactionTimeStamp: 123123.121, + transactionIdentifier: transactionId, + error: null, + originalTransaction: null); + } + + SKPaymentTransactionWrapper createFailedTransaction(String productId) { + return SKPaymentTransactionWrapper( + transactionIdentifier: '', + payment: SKPaymentWrapper(productIdentifier: productId), + transactionState: SKPaymentTransactionStateWrapper.failed, + transactionTimeStamp: 123123.121, + error: SKError( + code: 0, + domain: 'ios_domain', + userInfo: {'message': 'an error message'}), + originalTransaction: null); + } + + Future onMethodCall(MethodCall call) { + switch (call.method) { + case '-[SKPaymentQueue canMakePayments:]': + return Future.value(true); + case '-[InAppPurchasePlugin startProductRequest:result:]': + if (queryProductException != null) { + throw queryProductException!; + } + List productIDS = + List.castFrom(call.arguments); + assert(productIDS is List, 'invalid argument type'); + List invalidFound = []; + List products = []; + for (String productID in productIDS) { + if (!validProductIDs.contains(productID)) { + invalidFound.add(productID); + } else { + products.add(validProducts[productID]!); + } + } + SkProductResponseWrapper response = SkProductResponseWrapper( + products: products, invalidProductIdentifiers: invalidFound); + return Future>.value( + buildProductResponseMap(response)); + case '-[InAppPurchasePlugin restoreTransactions:result:]': + if (restoreException != null) { + throw restoreException!; + } + if (testRestoredError != null) { + InAppPurchaseIosPlatform.observer + .restoreCompletedTransactionsFailed(error: testRestoredError!); + return Future.sync(() {}); + } + if (!testRestoredTransactionsNull) { + InAppPurchaseIosPlatform.observer + .updatedTransactions(transactions: transactions); + } + InAppPurchaseIosPlatform.observer + .paymentQueueRestoreCompletedTransactionsFinished(); + + return Future.sync(() {}); + case '-[InAppPurchasePlugin retrieveReceiptData:result:]': + if (receiptData != null) { + return Future.value(receiptData); + } else { + throw PlatformException(code: 'no_receipt_data'); + } + case '-[InAppPurchasePlugin refreshReceipt:result:]': + receiptData = 'refreshed receipt data'; + return Future.sync(() {}); + case '-[InAppPurchasePlugin addPayment:result:]': + String id = call.arguments['productIdentifier']; + SKPaymentTransactionWrapper transaction = createPendingTransaction(id); + InAppPurchaseIosPlatform.observer + .updatedTransactions(transactions: [transaction]); + sleep(const Duration(milliseconds: 30)); + if (testTransactionFail) { + SKPaymentTransactionWrapper transaction_failed = + createFailedTransaction(id); + InAppPurchaseIosPlatform.observer + .updatedTransactions(transactions: [transaction_failed]); + } else { + SKPaymentTransactionWrapper transaction_finished = + createPurchasedTransaction( + id, transaction.transactionIdentifier ?? ''); + InAppPurchaseIosPlatform.observer + .updatedTransactions(transactions: [transaction_finished]); + } + break; + case '-[InAppPurchasePlugin finishTransaction:result:]': + finishedTransactions.add(createPurchasedTransaction( + call.arguments["productIdentifier"], + call.arguments["transactionIdentifier"])); + break; + } + return Future.sync(() {}); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_addtion_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_addtion_test.dart new file mode 100644 index 000000000000..f8b75195fc6e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_addtion_test.dart @@ -0,0 +1,41 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import 'fakes/fake_ios_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakeIOSPlatform fakeIOSPlatform = FakeIOSPlatform(); + + setUpAll(() { + SystemChannels.platform + .setMockMethodCallHandler(fakeIOSPlatform.onMethodCall); + }); + + group('present code redemption sheet', () { + test('null', () async { + expect( + await InAppPurchaseIosPlatformAddition().presentCodeRedemptionSheet(), + null); + }); + }); + + group('refresh receipt data', () { + test('should refresh receipt data', () async { + PurchaseVerificationData? receiptData = + await InAppPurchaseIosPlatformAddition() + .refreshPurchaseVerificationData(); + expect(receiptData, isNotNull); + expect(receiptData!.source, kIAPSource); + expect(receiptData.localVerificationData, 'refreshed receipt data'); + expect(receiptData.serverVerificationData, 'refreshed receipt data'); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart new file mode 100644 index 000000000000..a70e2d9191bb --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart @@ -0,0 +1,311 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_ios/src/store_kit_wrappers/enum_converters.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import 'fakes/fake_ios_platform.dart'; +import 'store_kit_wrappers/sk_test_stub_objects.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakeIOSPlatform fakeIOSPlatform = FakeIOSPlatform(); + + setUpAll(() { + SystemChannels.platform + .setMockMethodCallHandler(fakeIOSPlatform.onMethodCall); + }); + + setUp(() => fakeIOSPlatform.reset()); + + tearDown(() => fakeIOSPlatform.reset()); + + group('isAvailable', () { + test('true', () async { + expect(await InAppPurchaseIosPlatform.instance.isAvailable(), isTrue); + }); + }); + + group('query product list', () { + test('should get product list and correct invalid identifiers', () async { + final InAppPurchaseIosPlatform connection = InAppPurchaseIosPlatform(); + final ProductDetailsResponse response = await connection + .queryProductDetails(['123', '456', '789'].toSet()); + List products = response.productDetails; + expect(products.first.id, '123'); + expect(products[1].id, '456'); + expect(response.notFoundIDs, ['789']); + expect(response.error, isNull); + }); + + test( + 'if query products throws error, should get error object in the response', + () async { + fakeIOSPlatform.queryProductException = PlatformException( + code: 'error_code', + message: 'error_message', + details: {'info': 'error_info'}); + final InAppPurchaseIosPlatform connection = InAppPurchaseIosPlatform(); + final ProductDetailsResponse response = await connection + .queryProductDetails(['123', '456', '789'].toSet()); + expect(response.productDetails, []); + expect(response.notFoundIDs, ['123', '456', '789']); + expect(response.error, isNotNull); + expect(response.error!.source, kIAPSource); + expect(response.error!.code, 'error_code'); + expect(response.error!.message, 'error_message'); + expect(response.error!.details, {'info': 'error_info'}); + }); + }); + + group('restore purchases', () { + test('should emit restored transactions on purchase stream', () async { + Completer completer = Completer(); + Stream> stream = + InAppPurchaseIosPlatform.instance.purchaseStream; + + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + if (purchaseDetailsList.first.status == PurchaseStatus.restored) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + } + }); + + await InAppPurchaseIosPlatform.instance.restorePurchases(); + List details = await completer.future; + + expect(details.length, 2); + for (int i = 0; i < fakeIOSPlatform.transactions.length; i++) { + SKPaymentTransactionWrapper expected = fakeIOSPlatform.transactions[i]; + PurchaseDetails actual = details[i]; + + expect(actual.purchaseID, expected.transactionIdentifier); + expect(actual.verificationData, isNotNull); + expect(actual.status, PurchaseStatus.restored); + expect(actual.verificationData.localVerificationData, + fakeIOSPlatform.receiptData); + expect(actual.verificationData.serverVerificationData, + fakeIOSPlatform.receiptData); + expect(actual.pendingCompletePurchase, true); + } + }); + + test('should not block transaction updates', () async { + fakeIOSPlatform.transactions + .insert(0, fakeIOSPlatform.createPurchasedTransaction('foo', 'bar')); + Completer completer = Completer(); + Stream> stream = + InAppPurchaseIosPlatform.instance.purchaseStream; + + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + if (purchaseDetailsList.first.status == PurchaseStatus.purchased) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + } + }); + await InAppPurchaseIosPlatform.instance.restorePurchases(); + List details = await completer.future; + expect(details.length, 3); + for (int i = 0; i < fakeIOSPlatform.transactions.length; i++) { + SKPaymentTransactionWrapper expected = fakeIOSPlatform.transactions[i]; + PurchaseDetails actual = details[i]; + + expect(actual.purchaseID, expected.transactionIdentifier); + expect(actual.verificationData, isNotNull); + expect( + actual.status, + SKTransactionStatusConverter() + .toPurchaseStatus(expected.transactionState), + ); + expect(actual.verificationData.localVerificationData, + fakeIOSPlatform.receiptData); + expect(actual.verificationData.serverVerificationData, + fakeIOSPlatform.receiptData); + expect(actual.pendingCompletePurchase, true); + } + }); + + test('receipt error should populate null to verificationData.data', + () async { + fakeIOSPlatform.receiptData = null; + Completer completer = Completer(); + Stream> stream = + InAppPurchaseIosPlatform.instance.purchaseStream; + + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + if (purchaseDetailsList.first.status == PurchaseStatus.restored) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + } + }); + + await InAppPurchaseIosPlatform.instance.restorePurchases(); + List details = await completer.future; + + for (PurchaseDetails purchase in details) { + expect(purchase.verificationData.localVerificationData, isEmpty); + expect(purchase.verificationData.serverVerificationData, isEmpty); + } + }); + + test('test restore error', () { + fakeIOSPlatform.testRestoredError = SKError( + code: 123, + domain: 'error_test', + userInfo: {'message': 'errorMessage'}); + + expect( + () => InAppPurchaseIosPlatform.instance.restorePurchases(), + throwsA( + isA() + .having((error) => error.code, 'code', 123) + .having((error) => error.domain, 'domain', 'error_test') + .having((error) => error.userInfo, 'userInfo', + {'message': 'errorMessage'}), + )); + }); + }); + + group('make payment', () { + test( + 'buying non consumable, should get purchase objects in the purchase update callback', + () async { + List details = []; + Completer completer = Completer(); + Stream> stream = + InAppPurchaseIosPlatform.instance.purchaseStream; + + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + details.addAll(purchaseDetailsList); + if (purchaseDetailsList.first.status == PurchaseStatus.purchased) { + completer.complete(details); + subscription.cancel(); + } + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProductDetails.fromSKProduct(dummyProductWrapper), + applicationUserName: 'appName'); + await InAppPurchaseIosPlatform.instance + .buyNonConsumable(purchaseParam: purchaseParam); + + List result = await completer.future; + expect(result.length, 2); + expect(result.first.productID, dummyProductWrapper.productIdentifier); + }); + + test( + 'buying consumable, should get purchase objects in the purchase update callback', + () async { + List details = []; + Completer completer = Completer(); + Stream> stream = + InAppPurchaseIosPlatform.instance.purchaseStream; + + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + details.addAll(purchaseDetailsList); + if (purchaseDetailsList.first.status == PurchaseStatus.purchased) { + completer.complete(details); + subscription.cancel(); + } + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProductDetails.fromSKProduct(dummyProductWrapper), + applicationUserName: 'appName'); + await InAppPurchaseIosPlatform.instance + .buyConsumable(purchaseParam: purchaseParam); + + List result = await completer.future; + expect(result.length, 2); + expect(result.first.productID, dummyProductWrapper.productIdentifier); + }); + + test('buying consumable, should throw when autoConsume is false', () async { + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProductDetails.fromSKProduct(dummyProductWrapper), + applicationUserName: 'appName'); + expect( + () => InAppPurchaseIosPlatform.instance + .buyConsumable(purchaseParam: purchaseParam, autoConsume: false), + throwsA(isInstanceOf())); + }); + + test('should get failed purchase status', () async { + fakeIOSPlatform.testTransactionFail = true; + List details = []; + Completer completer = Completer(); + late IAPError error; + + Stream> stream = + InAppPurchaseIosPlatform.instance.purchaseStream; + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + details.addAll(purchaseDetailsList); + purchaseDetailsList.forEach((purchaseDetails) { + if (purchaseDetails.status == PurchaseStatus.error) { + error = purchaseDetails.error!; + completer.complete(error); + subscription.cancel(); + } + }); + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProductDetails.fromSKProduct(dummyProductWrapper), + applicationUserName: 'appName'); + await InAppPurchaseIosPlatform.instance + .buyNonConsumable(purchaseParam: purchaseParam); + + IAPError completerError = await completer.future; + expect(completerError.code, 'purchase_error'); + expect(completerError.source, kIAPSource); + expect(completerError.message, 'ios_domain'); + expect(completerError.details, {'message': 'an error message'}); + }); + }); + + group('complete purchase', () { + test('should complete purchase', () async { + List details = []; + Completer completer = Completer(); + Stream> stream = + InAppPurchaseIosPlatform.instance.purchaseStream; + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + details.addAll(purchaseDetailsList); + purchaseDetailsList.forEach((purchaseDetails) { + if (purchaseDetails.pendingCompletePurchase) { + InAppPurchaseIosPlatform.instance.completePurchase(purchaseDetails); + completer.complete(details); + subscription.cancel(); + } + }); + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProductDetails.fromSKProduct(dummyProductWrapper), + applicationUserName: 'appName'); + await InAppPurchaseIosPlatform.instance + .buyNonConsumable(purchaseParam: purchaseParam); + List result = await completer.future; + expect(result.length, 2); + expect(result.first.productID, dummyProductWrapper.productIdentifier); + expect(fakeIOSPlatform.finishedTransactions.length, 1); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart new file mode 100644 index 000000000000..98f6dac9598f --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -0,0 +1,231 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_ios/src/channel.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; +import 'sk_test_stub_objects.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakeIOSPlatform fakeIOSPlatform = FakeIOSPlatform(); + + setUpAll(() { + SystemChannels.platform + .setMockMethodCallHandler(fakeIOSPlatform.onMethodCall); + }); + + setUp(() {}); + + tearDown(() { + fakeIOSPlatform.testReturnNull = false; + }); + + group('sk_request_maker', () { + test('get products method channel', () async { + SkProductResponseWrapper productResponseWrapper = + await SKRequestMaker().startProductRequest(['xxx']); + expect( + productResponseWrapper.products, + isNotEmpty, + ); + expect( + productResponseWrapper.products.first.priceLocale.currencySymbol, + '\$', + ); + + expect( + productResponseWrapper.products.first.priceLocale.currencySymbol, + isNot('A'), + ); + expect( + productResponseWrapper.products.first.priceLocale.currencyCode, + 'USD', + ); + expect( + productResponseWrapper.invalidProductIdentifiers, + isNotEmpty, + ); + + expect( + fakeIOSPlatform.startProductRequestParam, + ['xxx'], + ); + }); + + test('get products method channel should throw exception', () async { + fakeIOSPlatform.getProductRequestFailTest = true; + expect( + SKRequestMaker().startProductRequest(['xxx']), + throwsException, + ); + fakeIOSPlatform.getProductRequestFailTest = false; + }); + + test('refreshed receipt', () async { + int receiptCountBefore = fakeIOSPlatform.refreshReceipt; + await SKRequestMaker().startRefreshReceiptRequest( + receiptProperties: {"isExpired": true}); + expect(fakeIOSPlatform.refreshReceipt, receiptCountBefore + 1); + expect(fakeIOSPlatform.refreshReceiptParam, + {"isExpired": true}); + }); + }); + + group('sk_receipt_manager', () { + test('should get receipt (faking it by returning a `receipt data` string)', + () async { + String receiptData = await SKReceiptManager.retrieveReceiptData(); + expect(receiptData, 'receipt data'); + }); + }); + + group('sk_payment_queue', () { + test('canMakePayment should return true', () async { + expect(await SKPaymentQueueWrapper.canMakePayments(), true); + }); + + test('canMakePayment returns false if method channel returns null', + () async { + fakeIOSPlatform.testReturnNull = true; + expect(await SKPaymentQueueWrapper.canMakePayments(), false); + }); + + test('transactions should return a valid list of transactions', () async { + expect(await SKPaymentQueueWrapper().transactions(), isNotEmpty); + }); + + test( + 'throws if observer is not set for payment queue before adding payment', + () async { + expect(SKPaymentQueueWrapper().addPayment(dummyPayment), + throwsAssertionError); + }); + + test('should add payment to the payment queue', () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentTransactionObserver observer = + TestPaymentTransactionObserver(); + queue.setTransactionObserver(observer); + await queue.addPayment(dummyPayment); + expect(fakeIOSPlatform.payments.first, equals(dummyPayment)); + }); + + test('should finish transaction', () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentTransactionObserver observer = + TestPaymentTransactionObserver(); + queue.setTransactionObserver(observer); + await queue.finishTransaction(dummyTransaction); + expect(fakeIOSPlatform.transactionsFinished.first, + equals(dummyTransaction.toFinishMap())); + }); + + test('should restore transaction', () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentTransactionObserver observer = + TestPaymentTransactionObserver(); + queue.setTransactionObserver(observer); + await queue.restoreTransactions(applicationUserName: 'aUserID'); + expect(fakeIOSPlatform.applicationNameHasTransactionRestored, 'aUserID'); + }); + }); + + group('Code Redemption Sheet', () { + test('presentCodeRedemptionSheet should not throw', () async { + expect(fakeIOSPlatform.presentCodeRedemption, false); + await SKPaymentQueueWrapper().presentCodeRedemptionSheet(); + expect(fakeIOSPlatform.presentCodeRedemption, true); + fakeIOSPlatform.presentCodeRedemption = false; + }); + }); +} + +class FakeIOSPlatform { + FakeIOSPlatform() { + channel.setMockMethodCallHandler(onMethodCall); + } + // get product request + List startProductRequestParam = []; + bool getProductRequestFailTest = false; + bool testReturnNull = false; + + // refresh receipt request + int refreshReceipt = 0; + late Map refreshReceiptParam; + + // payment queue + List payments = []; + List> transactionsFinished = []; + String applicationNameHasTransactionRestored = ''; + + // present Code Redemption + bool presentCodeRedemption = false; + + Future onMethodCall(MethodCall call) { + switch (call.method) { + // request makers + case '-[InAppPurchasePlugin startProductRequest:result:]': + List productIDS = + List.castFrom(call.arguments); + assert(productIDS is List, 'invalid argument type'); + startProductRequestParam = call.arguments; + if (getProductRequestFailTest) { + return Future>.value(null); + } + return Future>.value( + buildProductResponseMap(dummyProductResponseWrapper)); + case '-[InAppPurchasePlugin refreshReceipt:result:]': + refreshReceipt++; + refreshReceiptParam = + Map.castFrom(call.arguments); + return Future.sync(() {}); + // receipt manager + case '-[InAppPurchasePlugin retrieveReceiptData:result:]': + return Future.value('receipt data'); + // payment queue + case '-[SKPaymentQueue canMakePayments:]': + if (testReturnNull) { + return Future.value(null); + } + return Future.value(true); + case '-[SKPaymentQueue transactions]': + return Future>.value( + [buildTransactionMap(dummyTransaction)]); + case '-[InAppPurchasePlugin addPayment:result:]': + payments.add(SKPaymentWrapper.fromJson( + Map.from(call.arguments))); + return Future.sync(() {}); + case '-[InAppPurchasePlugin finishTransaction:result:]': + transactionsFinished.add(Map.from(call.arguments)); + return Future.sync(() {}); + case '-[InAppPurchasePlugin restoreTransactions:result:]': + applicationNameHasTransactionRestored = call.arguments; + return Future.sync(() {}); + case '-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]': + presentCodeRedemption = true; + return Future.sync(() {}); + } + return Future.sync(() {}); + } +} + +class TestPaymentTransactionObserver extends SKTransactionObserverWrapper { + void updatedTransactions( + {required List transactions}) {} + + void removedTransactions( + {required List transactions}) {} + + void restoreCompletedTransactionsFailed({required SKError error}) {} + + void paymentQueueRestoreCompletedTransactionsFinished() {} + + bool shouldAddStorePayment( + {required SKPaymentWrapper payment, required SKProductWrapper product}) { + return true; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart new file mode 100644 index 000000000000..9454a9d4ebee --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart @@ -0,0 +1,187 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_ios/src/types/app_store_product_details.dart'; +import 'package:in_app_purchase_ios/src/types/app_store_purchase_details.dart'; +import 'package:in_app_purchase_ios/src/store_kit_wrappers/sk_product_wrapper.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; +import 'package:test/test.dart'; + +import 'sk_test_stub_objects.dart'; + +void main() { + group('product related object wrapper test', () { + test( + 'SKProductSubscriptionPeriodWrapper should have property values consistent with map', + () { + final SKProductSubscriptionPeriodWrapper wrapper = + SKProductSubscriptionPeriodWrapper.fromJson( + buildSubscriptionPeriodMap(dummySubscription)!); + expect(wrapper, equals(dummySubscription)); + }); + + test( + 'SKProductSubscriptionPeriodWrapper should have properties to be default values if map is empty', + () { + final SKProductSubscriptionPeriodWrapper wrapper = + SKProductSubscriptionPeriodWrapper.fromJson({}); + expect(wrapper.numberOfUnits, 0); + expect(wrapper.unit, SKSubscriptionPeriodUnit.day); + }); + + test( + 'SKProductDiscountWrapper should have property values consistent with map', + () { + final SKProductDiscountWrapper wrapper = + SKProductDiscountWrapper.fromJson(buildDiscountMap(dummyDiscount)); + expect(wrapper, equals(dummyDiscount)); + }); + + test( + 'SKProductDiscountWrapper should have properties to be default if map is empty', + () { + final SKProductDiscountWrapper wrapper = + SKProductDiscountWrapper.fromJson({}); + expect(wrapper.price, ''); + expect(wrapper.priceLocale, + SKPriceLocaleWrapper(currencyCode: '', currencySymbol: '')); + expect(wrapper.numberOfPeriods, 0); + expect(wrapper.paymentMode, SKProductDiscountPaymentMode.payAsYouGo); + expect( + wrapper.subscriptionPeriod, + SKProductSubscriptionPeriodWrapper( + numberOfUnits: 0, unit: SKSubscriptionPeriodUnit.day)); + }); + + test('SKProductWrapper should have property values consistent with map', + () { + final SKProductWrapper wrapper = + SKProductWrapper.fromJson(buildProductMap(dummyProductWrapper)); + expect(wrapper, equals(dummyProductWrapper)); + }); + + test( + 'SKProductWrapper should have properties to be default if map is empty', + () { + final SKProductWrapper wrapper = + SKProductWrapper.fromJson({}); + expect(wrapper.productIdentifier, ''); + expect(wrapper.localizedTitle, ''); + expect(wrapper.localizedDescription, ''); + expect(wrapper.priceLocale, + SKPriceLocaleWrapper(currencyCode: '', currencySymbol: '')); + expect(wrapper.subscriptionGroupIdentifier, null); + expect(wrapper.price, ''); + expect(wrapper.subscriptionPeriod, null); + }); + + test('toProductDetails() should return correct Product object', () { + final SKProductWrapper wrapper = + SKProductWrapper.fromJson(buildProductMap(dummyProductWrapper)); + final AppStoreProductDetails product = + AppStoreProductDetails.fromSKProduct(wrapper); + expect(product.title, wrapper.localizedTitle); + expect(product.description, wrapper.localizedDescription); + expect(product.id, wrapper.productIdentifier); + expect(product.price, + wrapper.priceLocale.currencySymbol + wrapper.price.toString()); + expect(product.skProduct, wrapper); + }); + + test('SKProductResponse wrapper should match', () { + final SkProductResponseWrapper wrapper = + SkProductResponseWrapper.fromJson( + buildProductResponseMap(dummyProductResponseWrapper)); + expect(wrapper, equals(dummyProductResponseWrapper)); + }); + test('SKProductResponse wrapper should default to empty list', () { + final Map> productResponseMapEmptyList = + >{ + 'products': >[], + 'invalidProductIdentifiers': [], + }; + final SkProductResponseWrapper wrapper = + SkProductResponseWrapper.fromJson(productResponseMapEmptyList); + expect(wrapper.products.length, 0); + expect(wrapper.invalidProductIdentifiers.length, 0); + }); + + test('LocaleWrapper should have property values consistent with map', () { + final SKPriceLocaleWrapper wrapper = + SKPriceLocaleWrapper.fromJson(buildLocaleMap(dummyLocale)); + expect(wrapper, equals(dummyLocale)); + }); + }); + + group('Payment queue related object tests', () { + test('Should construct correct SKPaymentWrapper from json', () { + SKPaymentWrapper payment = + SKPaymentWrapper.fromJson(dummyPayment.toMap()); + expect(payment, equals(dummyPayment)); + }); + + test('Should construct correct SKError from json', () { + SKError error = SKError.fromJson(buildErrorMap(dummyError)); + expect(error, equals(dummyError)); + }); + + test('Should construct correct SKTransactionWrapper from json', () { + SKPaymentTransactionWrapper transaction = + SKPaymentTransactionWrapper.fromJson( + buildTransactionMap(dummyTransaction)); + expect(transaction, equals(dummyTransaction)); + }); + + test('toPurchaseDetails() should return correct PurchaseDetail object', () { + AppStorePurchaseDetails details = + AppStorePurchaseDetails.fromSKTransaction( + dummyTransaction, 'receipt data'); + expect(dummyTransaction.transactionIdentifier, details.purchaseID); + expect(dummyTransaction.payment.productIdentifier, details.productID); + expect(dummyTransaction.transactionTimeStamp, isNotNull); + expect((dummyTransaction.transactionTimeStamp! * 1000).toInt().toString(), + details.transactionDate); + expect(details.verificationData.localVerificationData, 'receipt data'); + expect(details.verificationData.serverVerificationData, 'receipt data'); + expect(details.verificationData.source, 'app_store'); + expect(details.skPaymentTransaction, dummyTransaction); + expect(details.pendingCompletePurchase, true); + }); + + test('SKPaymentTransactionWrapper.toFinishMap set correct value', () { + final SKPaymentTransactionWrapper transactionWrapper = + SKPaymentTransactionWrapper( + payment: dummyPayment, + transactionState: SKPaymentTransactionStateWrapper.failed, + transactionIdentifier: 'abcd'); + final Map finishMap = transactionWrapper.toFinishMap(); + expect(finishMap['transactionIdentifier'], 'abcd'); + expect(finishMap['productIdentifier'], dummyPayment.productIdentifier); + }); + + test( + 'SKPaymentTransactionWrapper.toFinishMap should set transactionIdentifier to null when necessary', + () { + final SKPaymentTransactionWrapper transactionWrapper = + SKPaymentTransactionWrapper( + payment: dummyPayment, + transactionState: SKPaymentTransactionStateWrapper.failed); + final Map finishMap = transactionWrapper.toFinishMap(); + expect(finishMap['transactionIdentifier'], null); + }); + + test('Should generate correct map of the payment object', () { + Map map = dummyPayment.toMap(); + expect(map['productIdentifier'], dummyPayment.productIdentifier); + expect(map['applicationUsername'], dummyPayment.applicationUsername); + + expect(map['requestData'], dummyPayment.requestData); + + expect(map['quantity'], dummyPayment.quantity); + + expect(map['simulatesAskToBuyInSandbox'], + dummyPayment.simulatesAskToBuyInSandbox); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart new file mode 100644 index 000000000000..d6c24460761e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart @@ -0,0 +1,147 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +final dummyPayment = SKPaymentWrapper( + productIdentifier: 'prod-id', + applicationUsername: 'app-user-name', + requestData: 'fake-data-utf8', + quantity: 2, + simulatesAskToBuyInSandbox: true); +final SKError dummyError = + SKError(code: 111, domain: 'dummy-domain', userInfo: {'key': 'value'}); + +final SKPaymentTransactionWrapper dummyOriginalTransaction = + SKPaymentTransactionWrapper( + transactionState: SKPaymentTransactionStateWrapper.purchased, + payment: dummyPayment, + originalTransaction: null, + transactionTimeStamp: 1231231231.00, + transactionIdentifier: '123123', + error: dummyError, +); + +final SKPaymentTransactionWrapper dummyTransaction = + SKPaymentTransactionWrapper( + transactionState: SKPaymentTransactionStateWrapper.purchased, + payment: dummyPayment, + originalTransaction: dummyOriginalTransaction, + transactionTimeStamp: 1231231231.00, + transactionIdentifier: '123123', + error: dummyError, +); + +final SKPriceLocaleWrapper dummyLocale = + SKPriceLocaleWrapper(currencySymbol: '\$', currencyCode: 'USD'); + +final SKProductSubscriptionPeriodWrapper dummySubscription = + SKProductSubscriptionPeriodWrapper( + numberOfUnits: 1, + unit: SKSubscriptionPeriodUnit.month, +); + +final SKProductDiscountWrapper dummyDiscount = SKProductDiscountWrapper( + price: '1.0', + priceLocale: dummyLocale, + numberOfPeriods: 1, + paymentMode: SKProductDiscountPaymentMode.payUpFront, + subscriptionPeriod: dummySubscription, +); + +final SKProductWrapper dummyProductWrapper = SKProductWrapper( + productIdentifier: 'id', + localizedTitle: 'title', + localizedDescription: 'description', + priceLocale: dummyLocale, + subscriptionGroupIdentifier: 'com.group', + price: '1.0', + subscriptionPeriod: dummySubscription, + introductoryPrice: dummyDiscount, +); + +final SkProductResponseWrapper dummyProductResponseWrapper = + SkProductResponseWrapper( + products: [dummyProductWrapper], + invalidProductIdentifiers: ['123'], +); + +Map buildLocaleMap(SKPriceLocaleWrapper local) { + return { + 'currencySymbol': local.currencySymbol, + 'currencyCode': local.currencyCode + }; +} + +Map? buildSubscriptionPeriodMap( + SKProductSubscriptionPeriodWrapper? sub) { + if (sub == null) { + return null; + } + return { + 'numberOfUnits': sub.numberOfUnits, + 'unit': SKSubscriptionPeriodUnit.values.indexOf(sub.unit), + }; +} + +Map buildDiscountMap(SKProductDiscountWrapper discount) { + return { + 'price': discount.price, + 'priceLocale': buildLocaleMap(discount.priceLocale), + 'numberOfPeriods': discount.numberOfPeriods, + 'paymentMode': + SKProductDiscountPaymentMode.values.indexOf(discount.paymentMode), + 'subscriptionPeriod': + buildSubscriptionPeriodMap(discount.subscriptionPeriod), + }; +} + +Map buildProductMap(SKProductWrapper product) { + return { + 'productIdentifier': product.productIdentifier, + 'localizedTitle': product.localizedTitle, + 'localizedDescription': product.localizedDescription, + 'priceLocale': buildLocaleMap(product.priceLocale), + 'subscriptionGroupIdentifier': product.subscriptionGroupIdentifier, + 'price': product.price, + 'subscriptionPeriod': + buildSubscriptionPeriodMap(product.subscriptionPeriod), + 'introductoryPrice': buildDiscountMap(product.introductoryPrice!), + }; +} + +Map buildProductResponseMap( + SkProductResponseWrapper response) { + List productsMap = response.products + .map((SKProductWrapper product) => buildProductMap(product)) + .toList(); + return { + 'products': productsMap, + 'invalidProductIdentifiers': response.invalidProductIdentifiers + }; +} + +Map buildErrorMap(SKError error) { + return { + 'code': error.code, + 'domain': error.domain, + 'userInfo': error.userInfo, + }; +} + +Map buildTransactionMap( + SKPaymentTransactionWrapper transaction) { + Map map = { + 'transactionState': SKPaymentTransactionStateWrapper.values + .indexOf(SKPaymentTransactionStateWrapper.purchased), + 'payment': transaction.payment.toMap(), + 'originalTransaction': transaction.originalTransaction == null + ? null + : buildTransactionMap(transaction.originalTransaction!), + 'transactionTimeStamp': transaction.transactionTimeStamp, + 'transactionIdentifier': transaction.transactionIdentifier, + 'error': buildErrorMap(transaction.error!), + }; + return map; +}