diff --git a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/InAppPurchasePluginTest.m b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/InAppPurchasePluginTest.m index fcfc3133291f..db7c1a88a4c8 100644 --- a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/InAppPurchasePluginTest.m +++ b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/InAppPurchasePluginTest.m @@ -16,7 +16,8 @@ @interface InAppPurchasePluginTest : XCTestCase @implementation InAppPurchasePluginTest - (void)setUp { - self.plugin = [[InAppPurchasePluginStub alloc] init]; + self.plugin = + [[InAppPurchasePluginStub alloc] initWithReceiptManager:[FIAPReceiptManagerStub new]]; } - (void)tearDown { @@ -171,4 +172,35 @@ - (void)testRestoreTransactions { 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); +} + @end diff --git a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/ProductRequestHandlerTest.m b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/ProductRequestHandlerTest.m index a0eb4704200b..09228a3cd4e3 100644 --- a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/ProductRequestHandlerTest.m +++ b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/ProductRequestHandlerTest.m @@ -52,4 +52,37 @@ - (void)testRequestHandlerWithProductRequestFailure { 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/example/ios/in_app_purchase_pluginTests/Stubs.h b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.h index 9ea6e6a54868..fe943a40a156 100644 --- a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.h +++ b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.h @@ -4,6 +4,7 @@ #import #import +#import "FIAPReceiptManager.h" #import "FIAPRequestHandler.h" #import "InAppPurchasePlugin.h" @@ -53,4 +54,11 @@ NS_ASSUME_NONNULL_BEGIN - (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/example/ios/in_app_purchase_pluginTests/Stubs.m b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.m index 02d9ea6c8385..338213bce212 100644 --- a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.m +++ b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.m @@ -98,7 +98,6 @@ - (void)start { } else { [self.delegate productsRequest:self didReceiveResponse:response]; } - [self.delegate requestDidFinish:self]; } @end @@ -134,6 +133,10 @@ - (SKProduct *)getProduct:(NSString *)productID { return [SKProduct new]; } +- (SKReceiptRefreshRequestStub *)getRefreshReceiptRequest:(NSDictionary *)properties { + return [[SKReceiptRefreshRequestStub alloc] initWithReceiptProperties:properties]; +} + @end @interface SKPaymentQueueStub () @@ -235,3 +238,37 @@ - (instancetype)initWithMap:(NSDictionary *)map { } @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/ios/Classes/FIAPReceiptManager.h b/packages/in_app_purchase/ios/Classes/FIAPReceiptManager.h new file mode 100644 index 000000000000..2e7d4e55212e --- /dev/null +++ b/packages/in_app_purchase/ios/Classes/FIAPReceiptManager.h @@ -0,0 +1,17 @@ +// Copyright 2019 The Chromium 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 + +- (NSString *)retrieveReceiptWithError:(FlutterError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/ios/Classes/FIAPReceiptManager.m b/packages/in_app_purchase/ios/Classes/FIAPReceiptManager.m new file mode 100644 index 000000000000..20043e2a3237 --- /dev/null +++ b/packages/in_app_purchase/ios/Classes/FIAPReceiptManager.m @@ -0,0 +1,29 @@ +// +// FIAPReceiptManager.m +// in_app_purchase +// +// Created by Chris Yang on 3/2/19. +// + +#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/ios/Classes/FIAPRequestHandler.h b/packages/in_app_purchase/ios/Classes/FIAPRequestHandler.h index a4f7cb484b5c..892f5f013cc9 100644 --- a/packages/in_app_purchase/ios/Classes/FIAPRequestHandler.h +++ b/packages/in_app_purchase/ios/Classes/FIAPRequestHandler.h @@ -12,7 +12,7 @@ typedef void (^ProductRequestCompletion)(SKProductsResponse *_Nullable response, @interface FIAPRequestHandler : NSObject -- (instancetype)initWithRequest:(SKProductsRequest *)request; +- (instancetype)initWithRequest:(SKRequest *)request; - (void)startProductRequestWithCompletionHandler:(ProductRequestCompletion)completion; @end diff --git a/packages/in_app_purchase/ios/Classes/FIAPRequestHandler.m b/packages/in_app_purchase/ios/Classes/FIAPRequestHandler.m index e3d97e0e4338..5dc2cea2e9db 100644 --- a/packages/in_app_purchase/ios/Classes/FIAPRequestHandler.m +++ b/packages/in_app_purchase/ios/Classes/FIAPRequestHandler.m @@ -10,13 +10,13 @@ @interface FIAPRequestHandler () @property(copy, nonatomic) ProductRequestCompletion completion; -@property(strong, nonatomic) SKProductsRequest *request; +@property(strong, nonatomic) SKRequest *request; @end @implementation FIAPRequestHandler -- (instancetype)initWithRequest:(SKProductsRequest *)request { +- (instancetype)initWithRequest:(SKRequest *)request { self = [super init]; if (self) { self.request = request; @@ -34,11 +34,16 @@ - (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; } } -// Reserved for other SKRequests. - (void)requestDidFinish:(SKRequest *)request { + if (self.completion) { + self.completion(nil, nil); + } } - (void)request:(SKRequest *)request didFailWithError:(NSError *)error { diff --git a/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.h b/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.h index f7abae534fca..67a381d31283 100644 --- a/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.h +++ b/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.h @@ -4,9 +4,12 @@ #import @class FIAPaymentQueueHandler; +@class FIAPReceiptManager; @interface InAppPurchasePlugin : NSObject @property(strong, nonatomic) FIAPaymentQueueHandler *paymentQueueHandler; +- (instancetype)initWithReceiptManager:(FIAPReceiptManager *)receiptManager; + @end diff --git a/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.m index c139679932bf..5464eba1aa0b 100644 --- a/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.m +++ b/packages/in_app_purchase/ios/Classes/InAppPurchasePlugin.m @@ -5,6 +5,7 @@ #import "InAppPurchasePlugin.h" #import #import "FIAObjectTranslator.h" +#import "FIAPReceiptManager.h" #import "FIAPRequestHandler.h" #import "FIAPaymentQueueHandler.h" @@ -24,6 +25,8 @@ @interface InAppPurchasePlugin () @property(strong, nonatomic) NSObject *messenger; @property(strong, nonatomic) NSObject *registrar; +@property(strong, nonatomic) FIAPReceiptManager *receiptManager; + @end @implementation InAppPurchasePlugin @@ -36,11 +39,18 @@ + (void)registerWithRegistrar:(NSObject *)registrar { [registrar addMethodCallDelegate:instance channel:channel]; } -- (instancetype)initWithRegistrar:(NSObject *)registrar { +- (instancetype)initWithReceiptManager:(FIAPReceiptManager *)receiptManager { self = [self init]; + self.receiptManager = receiptManager; + return self; +} + +- (instancetype)initWithRegistrar:(NSObject *)registrar { + self = [self initWithReceiptManager:[FIAPReceiptManager new]]; self.registrar = registrar; self.registry = [registrar textures]; self.messenger = [registrar messenger]; + __weak typeof(self) weakSelf = self; self.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:[SKPaymentQueue defaultQueue] @@ -79,6 +89,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [self finishTransaction:call result:result]; } else if ([@"-[InAppPurchasePlugin restoreTransactions:result:]" isEqualToString:call.method]) { [self restoreTransactions: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); } @@ -206,6 +220,50 @@ - (void)restoreTransactions:(FlutterMethodCall *)call result:(FlutterResult)resu [self.paymentQueueHandler restoreTransactions:call.arguments]; } +- (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.description + details:error.userInfo]); + return; + } + result(nil); + [weakSelf.requestHandlers removeObject:handler]; + }]; +} + #pragma mark - delegates - (void)handleTransactionsUpdated:(NSArray *)transactions { @@ -267,6 +325,10 @@ - (SKProduct *)getProduct:(NSString *)productID { return [self.productsCache objectForKey:productID]; } +- (SKReceiptRefreshRequest *)getRefreshReceiptRequest:(NSDictionary *)properties { + return [[SKReceiptRefreshRequest alloc] initWithReceiptProperties:properties]; +} + #pragma mark - getter - (NSSet *)requestHandlers { diff --git a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_receipt_manager.dart b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_receipt_manager.dart new file mode 100644 index 000000000000..eed09f1835ef --- /dev/null +++ b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_receipt_manager.dart @@ -0,0 +1,21 @@ +// Copyright 2019 The Chromium 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:in_app_purchase/src/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. + Future retrieveReceiptData() { + return channel + .invokeMethod('-[InAppPurchasePlugin retrieveReceiptData:result:]'); + } +} diff --git a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_request_maker.dart b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_request_maker.dart index 295e7277315a..353d34e5f9be 100644 --- a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_request_maker.dart +++ b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_request_maker.dart @@ -36,4 +36,20 @@ class SKRequestMaker { } 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/lib/store_kit_wrappers.dart b/packages/in_app_purchase/lib/store_kit_wrappers.dart index 49d1b0d163c0..c2a770e6ac8e 100644 --- a/packages/in_app_purchase/lib/store_kit_wrappers.dart +++ b/packages/in_app_purchase/lib/store_kit_wrappers.dart @@ -5,3 +5,4 @@ export 'src/store_kit_wrappers/sk_payment_queue_wrapper.dart'; export 'src/store_kit_wrappers/sk_product_wrapper.dart'; export 'src/store_kit_wrappers/sk_request_maker.dart'; +export 'src/store_kit_wrappers//sk_receipt_manager.dart'; diff --git a/packages/in_app_purchase/test/store_kit_wrappers/sk_receipt_manager_test.dart b/packages/in_app_purchase/test/store_kit_wrappers/sk_receipt_manager_test.dart new file mode 100644 index 000000000000..b35fd903b8ec --- /dev/null +++ b/packages/in_app_purchase/test/store_kit_wrappers/sk_receipt_manager_test.dart @@ -0,0 +1,26 @@ +// Copyright 2019 The Chromium 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:test/test.dart'; +import 'package:in_app_purchase/src/store_kit_wrappers/sk_receipt_manager.dart'; +import 'package:in_app_purchase/src/channel.dart'; + +import '../stub_in_app_purchase_platform.dart'; + +void main() { + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + + setUpAll(() => + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler)); + + group('retrieveReceiptData', () { + test('Should get result', () async { + stubPlatform.addResponse( + name: '-[InAppPurchasePlugin retrieveReceiptData:result:]', + value: 'dummy data'); + final String result = await SKReceiptManager().retrieveReceiptData(); + expect(result, 'dummy data'); + }); + }); +}