diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index ce53f98fc46..8f0f7839f70 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.13 + +* Added new native tests for more complete test coverage. + ## 0.3.12+1 * Fixes type of error code returned from native code in SKReceiptManager.retrieveReceiptData. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin+TestOnly.h b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin+TestOnly.h new file mode 100644 index 00000000000..ca090b6b992 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin+TestOnly.h @@ -0,0 +1,32 @@ +// 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 "FIAPRequestHandler.h" +#import "InAppPurchasePlugin.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; + +// Callback channel to dart used for when a function from the transaction observer is triggered. +@property(strong, nonatomic) FlutterMethodChannel *transactionObserverCallbackChannel; + +// Callback channel to dart used for when a function from the transaction observer is triggered. +@property(copy, nonatomic) FIAPRequestHandler * (^handlerFactory)(SKRequest *); + +// Convenience initializer with dependancy injection +- (instancetype)initWithReceiptManager:(FIAPReceiptManager *)receiptManager + handlerFactory:(FIAPRequestHandler * (^)(SKRequest *))handlerFactory; + +// Transaction observer methods +- (void)handleTransactionsUpdated:(NSArray *)transactions; +- (void)handleTransactionsRemoved:(NSArray *)transactions; +- (void)handleTransactionRestoreFailed:(NSError *)error; +- (void)restoreCompletedTransactionsFinished; +- (BOOL)shouldAddStorePayment:(SKPayment *)payment product:(SKProduct *)product; + +@end diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.m index 0a8b162bf1a..2e2abaaed35 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.m +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.m @@ -9,20 +9,14 @@ #import "FIAPReceiptManager.h" #import "FIAPRequestHandler.h" #import "FIAPaymentQueueHandler.h" +#import "InAppPurchasePlugin+TestOnly.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; -// Callback channel to dart used for when a function from the transaction observer is triggered. -@property(strong, nonatomic, readonly) FlutterMethodChannel *transactionObserverCallbackChannel; - // Callback channel to dart used for when a function from the payment queue delegate is triggered. @property(strong, nonatomic, readonly) FlutterMethodChannel *paymentQueueDelegateCallbackChannel; @property(strong, nonatomic, readonly) NSObject *registrar; @@ -52,6 +46,16 @@ - (instancetype)initWithReceiptManager:(FIAPReceiptManager *)receiptManager { _receiptManager = receiptManager; _requestHandlers = [NSMutableSet new]; _productsCache = [NSMutableDictionary new]; + _handlerFactory = ^FIAPRequestHandler *(SKRequest *request) { + return [[FIAPRequestHandler alloc] initWithRequest:request]; + }; + return self; +} + +- (instancetype)initWithReceiptManager:(FIAPReceiptManager *)receiptManager + handlerFactory:(FIAPRequestHandler * (^)(SKRequest *))handlerFactory { + self = [self initWithReceiptManager:receiptManager]; + _handlerFactory = [handlerFactory copy]; return self; } @@ -117,7 +121,7 @@ - (void)startProductRequestProductIdentifiers:(NSArray *)productIden FlutterError *_Nullable))completion { SKProductsRequest *request = [self getProductRequestWithIdentifiers:[NSSet setWithArray:productIdentifiers]]; - FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + FIAPRequestHandler *handler = self.handlerFactory(request); [self.requestHandlers addObject:handler]; __weak typeof(self) weakSelf = self; @@ -271,7 +275,7 @@ - (void)refreshReceiptReceiptProperties:(nullable NSDictionary *)receiptProperti request = [self getRefreshReceiptRequest:nil]; } - FIAPRequestHandler *handler = [[FIAPRequestHandler alloc] initWithRequest:request]; + FIAPRequestHandler *handler = self.handlerFactory(request); [self.requestHandlers addObject:handler]; __weak typeof(self) weakSelf = self; [handler startProductRequestWithCompletionHandler:^(SKProductsResponse *_Nullable response, @@ -282,6 +286,7 @@ - (void)refreshReceiptReceiptProperties:(nullable NSDictionary *)receiptProperti message:error.localizedDescription details:error.description]; completion(requestError); + return; } completion(nil); [weakSelf.requestHandlers removeObject:handler]; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchasePluginTests.m index 0695d9deb1c..905903df056 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchasePluginTests.m +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchasePluginTests.m @@ -5,6 +5,7 @@ #import #import #import "FIAPaymentQueueHandler.h" +#import "InAppPurchasePlugin+TestOnly.h" #import "Stubs.h" @import in_app_purchase_storekit; @@ -108,6 +109,142 @@ - (void)testGetProductResponse { [self waitForExpectations:@[ expectation ] timeout:5]; } +- (void)testFinishTransactionSucceeds { + NSDictionary *args = @{ + @"transactionIdentifier" : @"567", + @"productIdentifier" : @"unique_identifier", + }; + + NSDictionary *transactionMap = @{ + @"transactionIdentifier" : @"567", + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + }; + + SKPaymentTransactionStub *paymentTransaction = + [[SKPaymentTransactionStub alloc] initWithMap:transactionMap]; + NSArray *array = @[ paymentTransaction ]; + + FIAPaymentQueueHandler *mockHandler = OCMClassMock(FIAPaymentQueueHandler.class); + OCMStub([mockHandler getUnfinishedTransactions]).andReturn(array); + + self.plugin.paymentQueueHandler = mockHandler; + + FlutterError *error; + [self.plugin finishTransactionFinishMap:args error:&error]; + + XCTAssertNil(error); +} + +- (void)testFinishTransactionSucceedsWithNilTransaction { + NSDictionary *args = @{ + @"transactionIdentifier" : [NSNull null], + @"productIdentifier" : @"unique_identifier", + }; + + NSDictionary *paymentMap = @{ + @"productIdentifier" : @"123", + @"requestData" : @"abcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefgh", + @"quantity" : @(2), + @"applicationUsername" : @"app user name", + @"simulatesAskToBuyInSandbox" : @(NO) + }; + + NSDictionary *transactionMap = @{ + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : paymentMap, + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + }; + + SKPaymentTransactionStub *paymentTransaction = + [[SKPaymentTransactionStub alloc] initWithMap:transactionMap]; + + FIAPaymentQueueHandler *mockHandler = OCMClassMock(FIAPaymentQueueHandler.class); + OCMStub([mockHandler getUnfinishedTransactions]).andReturn(@[ paymentTransaction ]); + + self.plugin.paymentQueueHandler = mockHandler; + + FlutterError *error; + [self.plugin finishTransactionFinishMap:args error:&error]; + + XCTAssertNil(error); +} + +- (void)testGetProductResponseWithRequestError { + NSArray *argument = @[ @"123" ]; + XCTestExpectation *expectation = + [self expectationWithDescription:@"completion handler successfully called"]; + + id mockHandler = OCMClassMock([FIAPRequestHandler class]); + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] + initWithReceiptManager:nil + handlerFactory:^FIAPRequestHandler *(SKRequest *request) { + return mockHandler; + }]; + + NSError *error = [NSError errorWithDomain:@"errorDomain" + code:0 + userInfo:@{NSLocalizedDescriptionKey : @"description"}]; + + OCMStub([mockHandler + startProductRequestWithCompletionHandler:([OCMArg invokeBlockWithArgs:[NSNull null], error, + nil])]); + + [plugin + startProductRequestProductIdentifiers:argument + completion:^(SKProductsResponseMessage *_Nullable response, + FlutterError *_Nullable startProductRequestError) { + [expectation fulfill]; + XCTAssertNotNil(error); + XCTAssertNotNil(startProductRequestError); + XCTAssertEqualObjects( + startProductRequestError.code, + @"storekit_getproductrequest_platform_error"); + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; +} + +- (void)testGetProductResponseWithNoResponse { + NSArray *argument = @[ @"123" ]; + XCTestExpectation *expectation = + [self expectationWithDescription:@"completion handler successfully called"]; + + id mockHandler = OCMClassMock([FIAPRequestHandler class]); + + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] + initWithReceiptManager:nil + handlerFactory:^FIAPRequestHandler *(SKRequest *request) { + return mockHandler; + }]; + + NSError *error = [NSError errorWithDomain:@"errorDomain" + code:0 + userInfo:@{NSLocalizedDescriptionKey : @"description"}]; + + OCMStub([mockHandler + startProductRequestWithCompletionHandler:([OCMArg invokeBlockWithArgs:[NSNull null], + [NSNull null], nil])]); + + [plugin + startProductRequestProductIdentifiers:argument + completion:^(SKProductsResponseMessage *_Nullable response, + FlutterError *_Nullable startProductRequestError) { + [expectation fulfill]; + XCTAssertNotNil(error); + XCTAssertNotNil(startProductRequestError); + XCTAssertEqualObjects(startProductRequestError.code, + @"storekit_platform_no_response"); + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; +} + - (void)testAddPaymentShouldReturnFlutterErrorWhenPaymentFails { NSDictionary *argument = @{ @"productIdentifier" : @"123", @@ -132,6 +269,27 @@ - (void)testAddPaymentShouldReturnFlutterErrorWhenPaymentFails { XCTAssertEqualObjects(argument, error.details); } +- (void)testAddPaymentShouldReturnFlutterErrorWhenInvalidProduct { + NSDictionary *argument = @{ + // stubbed function will return nil for an empty productIdentifier + @"productIdentifier" : @"", + @"quantity" : @(1), + @"simulatesAskToBuyInSandbox" : @YES, + }; + + FlutterError *error; + + [self.plugin addPaymentPaymentMap:argument error:&error]; + + XCTAssertEqualObjects(@"storekit_invalid_payment_object", error.code); + XCTAssertEqualObjects( + @"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.", + error.message); + XCTAssertEqualObjects(argument, error.details); +} + - (void)testAddPaymentSuccessWithoutPaymentDiscount { NSDictionary *argument = @{ @"productIdentifier" : @"123", @@ -324,6 +482,56 @@ - (void)testRefreshReceiptRequest { [self waitForExpectations:@[ expectation ] timeout:5]; } +- (void)testRefreshReceiptRequestWithParams { + NSDictionary *properties = @{ + @"isExpired" : @NO, + @"isRevoked" : @NO, + @"isVolumePurchase" : @NO, + }; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"completion handler successfully called"]; + [self.plugin refreshReceiptReceiptProperties:properties + completion:^(FlutterError *_Nullable error) { + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; +} + +- (void)testRefreshReceiptRequestWithError { + NSDictionary *properties = @{ + @"isExpired" : @NO, + @"isRevoked" : @NO, + @"isVolumePurchase" : @NO, + }; + XCTestExpectation *expectation = + [self expectationWithDescription:@"completion handler successfully called"]; + + id mockHandler = OCMClassMock([FIAPRequestHandler class]); + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] + initWithReceiptManager:nil + handlerFactory:^FIAPRequestHandler *(SKRequest *request) { + return mockHandler; + }]; + + NSError *recieptError = [NSError errorWithDomain:@"errorDomain" + code:0 + userInfo:@{NSLocalizedDescriptionKey : @"description"}]; + + OCMStub([mockHandler + startProductRequestWithCompletionHandler:([OCMArg invokeBlockWithArgs:[NSNull null], + recieptError, nil])]); + + [plugin refreshReceiptReceiptProperties:properties + completion:^(FlutterError *_Nullable error) { + XCTAssertNotNil(error); + XCTAssertEqualObjects( + error.code, @"storekit_refreshreceiptrequest_platform_error"); + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; +} + /// presentCodeRedemptionSheetWithError:error is only available on iOS #if TARGET_OS_IOS - (void)testPresentCodeRedemptionSheet { @@ -442,6 +650,116 @@ - (void)testRemovePaymentQueueDelegate { } } +- (void)testHandleTransactionsUpdated { + NSDictionary *transactionMap = @{ + @"transactionIdentifier" : @"567", + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + }; + + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] initWithReceiptManager:nil]; + FlutterMethodChannel *mockChannel = OCMClassMock([FlutterMethodChannel class]); + plugin.transactionObserverCallbackChannel = mockChannel; + OCMStub([mockChannel invokeMethod:[OCMArg any] arguments:[OCMArg any]]); + + SKPaymentTransactionStub *paymentTransaction = + [[SKPaymentTransactionStub alloc] initWithMap:transactionMap]; + NSArray *array = [NSArray arrayWithObjects:paymentTransaction, nil]; + NSMutableArray *maps = [NSMutableArray new]; + [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:paymentTransaction]]; + + [plugin handleTransactionsUpdated:array]; + OCMVerify(times(1), [mockChannel invokeMethod:@"updatedTransactions" arguments:[OCMArg any]]); +} + +- (void)testHandleTransactionsRemoved { + NSDictionary *transactionMap = @{ + @"transactionIdentifier" : @"567", + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + }; + + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] initWithReceiptManager:nil]; + FlutterMethodChannel *mockChannel = OCMClassMock([FlutterMethodChannel class]); + plugin.transactionObserverCallbackChannel = mockChannel; + OCMStub([mockChannel invokeMethod:[OCMArg any] arguments:[OCMArg any]]); + + SKPaymentTransactionStub *paymentTransaction = + [[SKPaymentTransactionStub alloc] initWithMap:transactionMap]; + NSArray *array = [NSArray arrayWithObjects:paymentTransaction, nil]; + NSMutableArray *maps = [NSMutableArray new]; + [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:paymentTransaction]]; + + [plugin handleTransactionsRemoved:array]; + OCMVerify(times(1), [mockChannel invokeMethod:@"removedTransactions" arguments:maps]); +} + +- (void)testHandleTransactionRestoreFailed { + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] initWithReceiptManager:nil]; + FlutterMethodChannel *mockChannel = OCMClassMock([FlutterMethodChannel class]); + plugin.transactionObserverCallbackChannel = mockChannel; + OCMStub([mockChannel invokeMethod:[OCMArg any] arguments:[OCMArg any]]); + + NSError *error; + [plugin handleTransactionRestoreFailed:error]; + OCMVerify(times(1), [mockChannel invokeMethod:@"restoreCompletedTransactionsFailed" + arguments:[FIAObjectTranslator getMapFromNSError:error]]); +} + +- (void)testRestoreCompletedTransactionsFinished { + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] initWithReceiptManager:nil]; + FlutterMethodChannel *mockChannel = OCMClassMock([FlutterMethodChannel class]); + plugin.transactionObserverCallbackChannel = mockChannel; + OCMStub([mockChannel invokeMethod:[OCMArg any] arguments:[OCMArg any]]); + + [plugin restoreCompletedTransactionsFinished]; + OCMVerify(times(1), [mockChannel invokeMethod:@"paymentQueueRestoreCompletedTransactionsFinished" + arguments:nil]); +} + +- (void)testShouldAddStorePayment { + NSDictionary *paymentMap = @{ + @"productIdentifier" : @"123", + @"requestData" : @"abcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefghabcdefgh", + @"quantity" : @(2), + @"applicationUsername" : @"app user name", + @"simulatesAskToBuyInSandbox" : @(NO) + }; + + NSDictionary *productMap = @{ + @"price" : @"1", + @"priceLocale" : [FIAObjectTranslator getMapFromNSLocale:NSLocale.systemLocale], + @"productIdentifier" : @"123", + @"localizedTitle" : @"title", + @"localizedDescription" : @"des", + }; + + SKMutablePayment *payment = [FIAObjectTranslator getSKMutablePaymentFromMap:paymentMap]; + SKProductStub *product = [[SKProductStub alloc] initWithMap:productMap]; + + InAppPurchasePlugin *plugin = [[InAppPurchasePlugin alloc] initWithReceiptManager:nil]; + FlutterMethodChannel *mockChannel = OCMClassMock([FlutterMethodChannel class]); + plugin.transactionObserverCallbackChannel = mockChannel; + OCMStub([mockChannel invokeMethod:[OCMArg any] arguments:[OCMArg any]]); + + NSDictionary *args = @{ + @"payment" : [FIAObjectTranslator getMapFromSKPayment:payment], + @"product" : [FIAObjectTranslator getMapFromSKProduct:product] + }; + + BOOL result = [plugin shouldAddStorePayment:payment product:product]; + XCTAssertEqual(result, NO); + OCMVerify(times(1), [mockChannel invokeMethod:@"shouldAddStorePayment" arguments:args]); +} + #if TARGET_OS_IOS - (void)testShowPriceConsentIfNeeded { FIAPaymentQueueHandler *mockQueueHandler = OCMClassMock(FIAPaymentQueueHandler.class); diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.h b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.h index d4e8df3eba7..2ef8e23181a 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.h +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.h @@ -23,6 +23,7 @@ API_AVAILABLE(ios(11.2), macos(10.13.2)) @end @interface SKProductRequestStub : SKProductsRequest +@property(assign, nonatomic) BOOL returnError; - (instancetype)initWithProductIdentifiers:(NSSet *)productIdentifiers; - (instancetype)initWithFailureError:(NSError *)error; @end @@ -34,6 +35,9 @@ API_AVAILABLE(ios(11.2), macos(10.13.2)) @interface InAppPurchasePluginStub : InAppPurchasePlugin @end +@interface SKRequestStub : SKRequest +@end + @interface SKPaymentQueueStub : SKPaymentQueue @property(assign, nonatomic) SKPaymentTransactionState testState; @property(strong, nonatomic, nullable) id observer; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.m b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.m index 38081bb18f9..b4dba710f02 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.m +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/Stubs.m @@ -111,8 +111,13 @@ - (void)start { for (NSString *identifier in self.identifers) { [productArray addObject:@{@"productIdentifier" : identifier}]; } - SKProductsResponseStub *response = - [[SKProductsResponseStub alloc] initWithMap:@{@"products" : productArray}]; + SKProductsResponseStub *response; + if (self.returnError) { + response = nil; + } else { + response = [[SKProductsResponseStub alloc] initWithMap:@{@"products" : productArray}]; + } + if (self.error) { [self.delegate request:self didFailWithError:self.error]; } else { @@ -140,7 +145,6 @@ - (instancetype)initWithMap:(NSDictionary *)map { @end @interface InAppPurchasePluginStub () - @end @implementation InAppPurchasePluginStub @@ -150,6 +154,9 @@ - (SKProductRequestStub *)getProductRequestWithIdentifiers:(NSSet *)identifiers } - (SKProduct *)getProduct:(NSString *)productID { + if ([productID isEqualToString:@""]) { + return nil; + } return [[SKProductStub alloc] initWithProductID:productID]; } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml index fcfcde9fc53..852bd127fe1 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_storekit description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.3.12+1 +version: 0.3.13 environment: sdk: ^3.2.3