[in_app_purchase] Implements transaction caching for StoreKit (#4985)
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 c5df521..087c6e3 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.0+3
+
+* Implements transaction caching for StoreKit ensuring transactions are delivered to the Flutter client.
+
## 0.3.0+2
* Internal code cleanup for stricter analysis options.
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj
index a880501..3977d54 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 46;
+ objectVersion = 50;
objects = {
/* Begin PBXBuildFile section */
@@ -22,6 +22,7 @@
A5279298219369C600FF69E6 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5279297219369C600FF69E6 /* StoreKit.framework */; };
A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */; };
F67646F82681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */; };
+ F6995BDD27CF73000050EA78 /* FIATransactionCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F6995BDC27CF73000050EA78 /* FIATransactionCacheTests.m */; };
F78AF3142342BC89008449C7 /* PaymentQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F78AF3132342BC89008449C7 /* PaymentQueueTests.m */; };
/* End PBXBuildFile section */
@@ -78,6 +79,7 @@
A59001A821E69658004A3E5E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E4F9651425A612301059769C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIAPPaymentQueueDeleteTests.m; sourceTree = "<group>"; };
+ F6995BDC27CF73000050EA78 /* FIATransactionCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIATransactionCacheTests.m; sourceTree = "<group>"; };
F6E5D5F926131C4800C68BED /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = "<group>"; };
F78AF3132342BC89008449C7 /* PaymentQueueTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PaymentQueueTests.m; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -190,6 +192,7 @@
F78AF3132342BC89008449C7 /* PaymentQueueTests.m */,
688DE35021F2A5A100EA2684 /* TranslatorTests.m */,
F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */,
+ F6995BDC27CF73000050EA78 /* FIATransactionCacheTests.m */,
);
path = RunnerTests;
sourceTree = "<group>";
@@ -254,7 +257,7 @@
isa = PBXProject;
attributes = {
DefaultBuildSystemTypeForWorkspace = Original;
- LastUpgradeCheck = 1100;
+ LastUpgradeCheck = 1300;
ORGANIZATIONNAME = "The Flutter Authors";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@@ -406,6 +409,7 @@
F67646F82681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m in Sources */,
6896B34621E9363700D37AEF /* ProductRequestHandlerTests.m in Sources */,
688DE35121F2A5A100EA2684 /* TranslatorTests.m in Sources */,
+ F6995BDD27CF73000050EA78 /* FIATransactionCacheTests.m in Sources */,
A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */,
6896B34C21EEB4B800D37AEF /* Stubs.m in Sources */,
);
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index 3bd47ec..a8adf88 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
- LastUpgradeVersion = "1100"
+ LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/FIATransactionCacheTests.m b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/FIATransactionCacheTests.m
new file mode 100644
index 0000000..1ba0aea
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/FIATransactionCacheTests.m
@@ -0,0 +1,63 @@
+// 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 <XCTest/XCTest.h>
+
+@import in_app_purchase_storekit;
+
+@interface FIATransactionCacheTests : XCTestCase
+
+@end
+
+@implementation FIATransactionCacheTests
+
+- (void)testAddObjectsForNewKey {
+ NSArray *dummyArray = @[ @1, @2, @3 ];
+ FIATransactionCache *cache = [[FIATransactionCache alloc] init];
+ [cache addObjects:dummyArray forKey:TransactionCacheKeyUpdatedTransactions];
+
+ XCTAssertEqual(dummyArray, [cache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+}
+
+- (void)testAddObjectsForExistingKey {
+ NSArray *dummyArray = @[ @1, @2, @3 ];
+ FIATransactionCache *cache = [[FIATransactionCache alloc] init];
+ [cache addObjects:dummyArray forKey:TransactionCacheKeyUpdatedTransactions];
+
+ XCTAssertEqual(dummyArray, [cache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+
+ [cache addObjects:@[ @4, @5, @6 ] forKey:TransactionCacheKeyUpdatedTransactions];
+
+ NSArray *expected = @[ @1, @2, @3, @4, @5, @6 ];
+ XCTAssertEqualObjects(expected, [cache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+}
+
+- (void)testGetObjectsForNonExistingKey {
+ FIATransactionCache *cache = [[FIATransactionCache alloc] init];
+ XCTAssertNil([cache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+}
+
+- (void)testClear {
+ NSArray *fakeUpdatedTransactions = @[ @1, @2, @3 ];
+ NSArray *fakeRemovedTransactions = @[ @"Remove 1", @"Remove 2", @"Remove 3" ];
+ NSArray *fakeUpdatedDownloads = @[ @"Download 1", @"Download 2" ];
+ FIATransactionCache *cache = [[FIATransactionCache alloc] init];
+ [cache addObjects:fakeUpdatedTransactions forKey:TransactionCacheKeyUpdatedTransactions];
+ [cache addObjects:fakeRemovedTransactions forKey:TransactionCacheKeyRemovedTransactions];
+ [cache addObjects:fakeUpdatedDownloads forKey:TransactionCacheKeyUpdatedDownloads];
+
+ XCTAssertEqual(fakeUpdatedTransactions,
+ [cache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+ XCTAssertEqual(fakeRemovedTransactions,
+ [cache getObjectsForKey:TransactionCacheKeyRemovedTransactions]);
+ XCTAssertEqual(fakeUpdatedDownloads,
+ [cache getObjectsForKey:TransactionCacheKeyUpdatedDownloads]);
+
+ [cache clear];
+
+ XCTAssertNil([cache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+ XCTAssertNil([cache getObjectsForKey:TransactionCacheKeyRemovedTransactions]);
+ XCTAssertNil([cache getObjectsForKey:TransactionCacheKeyUpdatedDownloads]);
+}
+@end
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchasePluginTests.m
index fcb2c94..c89589c 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchasePluginTests.m
+++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchasePluginTests.m
@@ -74,46 +74,63 @@
XCTAssertTrue([resultArray.firstObject[@"productIdentifier"] isEqualToString:@"123"]);
}
-- (void)testAddPaymentFailure {
+- (void)testAddPaymentShouldReturnFlutterErrorWhenArgumentsAreInvalid {
XCTestExpectation *expectation =
- [self expectationWithDescription:@"result should return failed state"];
+ [self expectationWithDescription:
+ @"Result should contain a FlutterError when invalid parameters are passed in."];
+ NSString *argument = @"Invalid argument";
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<SKPaymentTransaction *> *_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];
+ arguments:argument];
+ [self.plugin handleMethodCall:call
+ result:^(id _Nullable result) {
+ FlutterError *error = result;
+ XCTAssertEqualObjects(@"storekit_invalid_argument", error.code);
+ XCTAssertEqualObjects(@"Argument type of addPayment is not a Dictionary",
+ error.message);
+ XCTAssertEqualObjects(argument, error.details);
+ [expectation fulfill];
+ }];
+
+ [self waitForExpectations:@[ expectation ] timeout:5];
+}
+
+- (void)testAddPaymentShouldReturnFlutterErrorWhenPaymentFails {
+ NSDictionary *arguments = @{
+ @"productIdentifier" : @"123",
+ @"quantity" : @(1),
+ @"simulatesAskToBuyInSandbox" : @YES,
+ };
+ XCTestExpectation *expectation =
+ [self expectationWithDescription:@"Result should return failed state."];
+ FlutterMethodCall *call =
+ [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]"
+ arguments:arguments];
+
+ FIAPaymentQueueHandler *mockHandler = OCMClassMock(FIAPaymentQueueHandler.class);
+ OCMStub([mockHandler addPayment:[OCMArg any]]).andReturn(NO);
+ self.plugin.paymentQueueHandler = mockHandler;
[self.plugin handleMethodCall:call
- result:^(id r){
+ result:^(id _Nullable result) {
+ FlutterError *error = result;
+ XCTAssertEqualObjects(@"storekit_duplicate_product_object", error.code);
+ XCTAssertEqualObjects(
+ @"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.",
+ error.message);
+ XCTAssertEqualObjects(arguments, error.details);
+ [expectation fulfill];
}];
+
[self waitForExpectations:@[ expectation ] timeout:5];
- XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStateFailed);
+ OCMVerify(times(1), [mockHandler addPayment:[OCMArg any]]);
}
- (void)testAddPaymentSuccessWithoutPaymentDiscount {
XCTestExpectation *expectation =
- [self expectationWithDescription:@"result should return success state"];
+ [self expectationWithDescription:@"Result should return success state"];
FlutterMethodCall *call =
[FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]"
arguments:@{
@@ -121,38 +138,20 @@
@"quantity" : @(1),
@"simulatesAskToBuyInSandbox" : @YES,
}];
- SKPaymentQueueStub *queue = [SKPaymentQueueStub new];
- queue.testState = SKPaymentTransactionStatePurchased;
- __block SKPaymentTransaction *transactionForUpdateBlock;
- self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue
- transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
- SKPaymentTransaction *transaction = transactions[0];
- if (transaction.transactionState == SKPaymentTransactionStatePurchased) {
- transactionForUpdateBlock = transaction;
- if (@available(iOS 12.2, *)) {
- XCTAssertNil(transaction.payment.paymentDiscount);
- }
- [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];
+ FIAPaymentQueueHandler *mockHandler = OCMClassMock(FIAPaymentQueueHandler.class);
+ OCMStub([mockHandler addPayment:[OCMArg any]]).andReturn(YES);
+ self.plugin.paymentQueueHandler = mockHandler;
[self.plugin handleMethodCall:call
- result:^(id r){
+ result:^(id _Nullable result) {
+ XCTAssertNil(result);
+ [expectation fulfill];
}];
[self waitForExpectations:@[ expectation ] timeout:5];
- XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStatePurchased);
}
- (void)testAddPaymentSuccessWithPaymentDiscount {
XCTestExpectation *expectation =
- [self expectationWithDescription:@"result should return success state"];
+ [self expectationWithDescription:@"Result should return success state"];
FlutterMethodCall *call =
[FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]"
arguments:@{
@@ -167,81 +166,86 @@
@"timestamp" : @(1635847102),
}
}];
- SKPaymentQueueStub *queue = [SKPaymentQueueStub new];
- queue.testState = SKPaymentTransactionStatePurchased;
- __block SKPaymentTransaction *transactionForUpdateBlock;
- self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue
- transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
- SKPaymentTransaction *transaction = transactions[0];
- if (transaction.transactionState == SKPaymentTransactionStatePurchased) {
- transactionForUpdateBlock = transaction;
- if (@available(iOS 12.2, *)) {
- SKPaymentDiscount *paymentDiscount = transaction.payment.paymentDiscount;
- XCTAssertEqual(paymentDiscount.identifier, @"test_identifier");
- XCTAssertEqual(paymentDiscount.keyIdentifier, @"test_key_identifier");
- XCTAssertEqualObjects(
- paymentDiscount.nonce,
- [[NSUUID alloc] initWithUUIDString:@"4a11a9cc-3bc3-11ec-8d3d-0242ac130003"]);
- XCTAssertEqual(paymentDiscount.signature, @"test_signature");
- XCTAssertEqual(paymentDiscount.timestamp, @(1635847102));
- }
- [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];
+
+ FIAPaymentQueueHandler *mockHandler = OCMClassMock(FIAPaymentQueueHandler.class);
+ OCMStub([mockHandler addPayment:[OCMArg any]]).andReturn(YES);
+ self.plugin.paymentQueueHandler = mockHandler;
[self.plugin handleMethodCall:call
- result:^(id r){
+ result:^(id _Nullable result) {
+ XCTAssertNil(result);
+ [expectation fulfill];
}];
[self waitForExpectations:@[ expectation ] timeout:5];
- XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStatePurchased);
+ OCMVerify(
+ times(1),
+ [mockHandler
+ addPayment:[OCMArg checkWithBlock:^BOOL(id obj) {
+ SKPayment *payment = obj;
+ if (@available(iOS 12.2, *)) {
+ SKPaymentDiscount *discount = payment.paymentDiscount;
+
+ return [discount.identifier isEqual:@"test_identifier"] &&
+ [discount.keyIdentifier isEqual:@"test_key_identifier"] &&
+ [discount.nonce
+ isEqual:[[NSUUID alloc]
+ initWithUUIDString:@"4a11a9cc-3bc3-11ec-8d3d-0242ac130003"]] &&
+ [discount.signature isEqual:@"test_signature"] &&
+ [discount.timestamp isEqual:@(1635847102)];
+ }
+
+ return YES;
+ }]]);
}
- (void)testAddPaymentFailureWithInvalidPaymentDiscount {
- XCTestExpectation *expectation =
- [self expectationWithDescription:@"result should return success state"];
- FlutterMethodCall *call =
- [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]"
- arguments:@{
- @"productIdentifier" : @"123",
- @"quantity" : @(1),
- @"simulatesAskToBuyInSandbox" : @YES,
- @"paymentDiscount" : @{
- @"keyIdentifier" : @"test_key_identifier",
- @"nonce" : @"4a11a9cc-3bc3-11ec-8d3d-0242ac130003",
- @"signature" : @"test_signature",
- @"timestamp" : @(1635847102),
- }
- }];
+ // Support for payment discount is only available on iOS 12.2 and higher.
+ if (@available(iOS 12.2, *)) {
+ XCTestExpectation *expectation =
+ [self expectationWithDescription:@"Result should return success state"];
+ NSDictionary *arguments = @{
+ @"productIdentifier" : @"123",
+ @"quantity" : @(1),
+ @"simulatesAskToBuyInSandbox" : @YES,
+ @"paymentDiscount" : @{
+ @"keyIdentifier" : @"test_key_identifier",
+ @"nonce" : @"4a11a9cc-3bc3-11ec-8d3d-0242ac130003",
+ @"signature" : @"test_signature",
+ @"timestamp" : @(1635847102),
+ }
+ };
+ FlutterMethodCall *call =
+ [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]"
+ arguments:arguments];
- [self.plugin
- handleMethodCall:call
- result:^(id r) {
- XCTAssertTrue([r isKindOfClass:FlutterError.class]);
- FlutterError *result = r;
- XCTAssertEqualObjects(result.code, @"storekit_invalid_payment_discount_object");
- XCTAssertEqualObjects(result.message,
- @"You have requested a payment and specified a payment "
- @"discount with invalid properties. When specifying a "
- @"payment discount the 'identifier' field is mandatory.");
- XCTAssertEqualObjects(result.details, call.arguments);
- [expectation fulfill];
- }];
+ FIAPaymentQueueHandler *mockHandler = OCMClassMock(FIAPaymentQueueHandler.class);
+ id translator = OCMClassMock(FIAObjectTranslator.class);
- [self waitForExpectations:@[ expectation ] timeout:5];
+ NSString *error = @"Some error occurred";
+ OCMStub(ClassMethod([translator
+ getSKPaymentDiscountFromMap:[OCMArg any]
+ withError:(NSString __autoreleasing **)[OCMArg setTo:error]]))
+ .andReturn(nil);
+ self.plugin.paymentQueueHandler = mockHandler;
+ [self.plugin
+ handleMethodCall:call
+ result:^(id _Nullable result) {
+ FlutterError *error = result;
+ XCTAssertEqualObjects(@"storekit_invalid_payment_discount_object", error.code);
+ XCTAssertEqualObjects(
+ @"You have requested a payment and specified a "
+ @"payment discount with invalid properties. Some error occurred",
+ error.message);
+ XCTAssertEqualObjects(arguments, error.details);
+ [expectation fulfill];
+ }];
+ [self waitForExpectations:@[ expectation ] timeout:5];
+ OCMVerify(never(), [mockHandler addPayment:[OCMArg any]]);
+ }
}
- (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:@{
@@ -249,33 +253,19 @@
@"quantity" : @(1),
@"simulatesAskToBuyInSandbox" : [NSNull null],
}];
- SKPaymentQueueStub *queue = [SKPaymentQueueStub new];
- queue.testState = SKPaymentTransactionStatePurchased;
- __block SKPaymentTransaction *transactionForUpdateBlock;
- self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue
- transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
- SKPaymentTransaction *transaction = transactions[0];
- if (transaction.transactionState == SKPaymentTransactionStatePurchased) {
- transactionForUpdateBlock = transaction;
- [expectation fulfill];
- }
- if (!transaction.payment.simulatesAskToBuyInSandbox) {
- [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];
+ FIAPaymentQueueHandler *mockHandler = OCMClassMock(FIAPaymentQueueHandler.class);
+ OCMStub([mockHandler addPayment:[OCMArg any]]).andReturn(YES);
+ self.plugin.paymentQueueHandler = mockHandler;
[self.plugin handleMethodCall:call
- result:^(id r){
+ result:^(id _Nullable result) {
+ XCTAssertNil(result);
+ [expectation fulfill];
}];
- [self waitForExpectations:@[ expectation, simulatesAskToBuyInSandboxExpectation ] timeout:5];
- XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStatePurchased);
+ [self waitForExpectations:@[ expectation ] timeout:5];
+ OCMVerify(times(1), [mockHandler addPayment:[OCMArg checkWithBlock:^BOOL(id obj) {
+ SKPayment *payment = obj;
+ return !payment.simulatesAskToBuyInSandbox;
+ }]]);
}
- (void)testRestoreTransactions {
@@ -297,7 +287,8 @@
[expectation fulfill];
}
shouldAddStorePayment:nil
- updatedDownloads:nil];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
[queue addTransactionObserver:self.plugin.paymentQueueHandler];
[self.plugin handleMethodCall:call
result:^(id r){
@@ -393,13 +384,15 @@
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.paymentQueueHandler =
+ [[FIAPaymentQueueHandler alloc] initWithQueue:mockQueue
+ transactionsUpdated:nil
+ transactionRemoved:nil
+ restoreTransactionFailed:nil
+ restoreCompletedTransactionsFinished:nil
+ shouldAddStorePayment:nil
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
[self.plugin handleMethodCall:call
result:^(id r) {
resultArray = r;
@@ -409,46 +402,40 @@
XCTAssertEqualObjects(resultArray, @[ transactionMap ]);
}
-- (void)testStartAndStopObservingPaymentQueue {
+- (void)testStartObservingPaymentQueue {
+ XCTestExpectation *expectation =
+ [self expectationWithDescription:@"Should return success result"];
FlutterMethodCall *startCall = [FlutterMethodCall
methodCallWithMethodName:@"-[SKPaymentQueue startObservingTransactionQueue]"
arguments:nil];
+ FIAPaymentQueueHandler *mockHandler = OCMClassMock([FIAPaymentQueueHandler class]);
+ self.plugin.paymentQueueHandler = mockHandler;
+ [self.plugin handleMethodCall:startCall
+ result:^(id _Nullable result) {
+ XCTAssertNil(result);
+ [expectation fulfill];
+ }];
+
+ [self waitForExpectations:@[ expectation ] timeout:5];
+ OCMVerify(times(1), [mockHandler startObservingPaymentQueue]);
+}
+
+- (void)testStopObservingPaymentQueue {
+ XCTestExpectation *expectation =
+ [self expectationWithDescription:@"Should return success result"];
FlutterMethodCall *stopCall =
[FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue stopObservingTransactionQueue]"
arguments:nil];
-
- SKPaymentQueueStub *queue = [SKPaymentQueueStub new];
-
- self.plugin.paymentQueueHandler =
- [[FIAPaymentQueueHandler alloc] initWithQueue:queue
- transactionsUpdated:nil
- transactionRemoved:nil
- restoreTransactionFailed:nil
- restoreCompletedTransactionsFinished:nil
- shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment,
- SKProduct *_Nonnull product) {
- return YES;
- }
- updatedDownloads:nil];
-
- // Check that there is no observer to start with.
- XCTAssertNil(queue.observer);
-
- // Start observing
- [self.plugin handleMethodCall:startCall
- result:^(id r){
- }];
-
- // Observer should be set
- XCTAssertNotNil(queue.observer);
-
- // Stop observing
+ FIAPaymentQueueHandler *mockHandler = OCMClassMock([FIAPaymentQueueHandler class]);
+ self.plugin.paymentQueueHandler = mockHandler;
[self.plugin handleMethodCall:stopCall
- result:^(id r){
+ result:^(id _Nullable result) {
+ XCTAssertNil(result);
+ [expectation fulfill];
}];
- // No observer should be set
- XCTAssertNil(queue.observer);
+ [self waitForExpectations:@[ expectation ] timeout:5];
+ OCMVerify(times(1), [mockHandler stopObservingPaymentQueue]);
}
- (void)testRegisterPaymentQueueDelegate {
@@ -464,7 +451,8 @@
restoreTransactionFailed:nil
restoreCompletedTransactionsFinished:nil
shouldAddStorePayment:nil
- updatedDownloads:nil];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
// Verify the delegate is nil before we register one.
XCTAssertNil(self.plugin.paymentQueueHandler.delegate);
@@ -491,7 +479,8 @@
restoreTransactionFailed:nil
restoreCompletedTransactionsFinished:nil
shouldAddStorePayment:nil
- updatedDownloads:nil];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
self.plugin.paymentQueueHandler.delegate = OCMProtocolMock(@protocol(SKPaymentQueueDelegate));
// Verify the delegate is not nil before removing it.
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/PaymentQueueTests.m b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/PaymentQueueTests.m
index e267be1..2f8d585 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/PaymentQueueTests.m
+++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/PaymentQueueTests.m
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#import <OCMock/OCMock.h>
#import <XCTest/XCTest.h>
#import "Stubs.h"
@@ -59,10 +60,11 @@
shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
return YES;
}
- updatedDownloads:nil];
- [queue addTransactionObserver:handler];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
SKPayment *payment =
[SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]];
+ [handler startObservingPaymentQueue];
[handler addPayment:payment];
[self waitForExpectations:@[ expectation ] timeout:5];
XCTAssertEqual(tran.transactionState, SKPaymentTransactionStatePurchased);
@@ -87,10 +89,12 @@
shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
return YES;
}
- updatedDownloads:nil];
- [queue addTransactionObserver:handler];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
+
SKPayment *payment =
[SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]];
+ [handler startObservingPaymentQueue];
[handler addPayment:payment];
[self waitForExpectations:@[ expectation ] timeout:5];
XCTAssertEqual(tran.transactionState, SKPaymentTransactionStateFailed);
@@ -115,10 +119,12 @@
shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
return YES;
}
- updatedDownloads:nil];
- [queue addTransactionObserver:handler];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
+
SKPayment *payment =
[SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]];
+ [handler startObservingPaymentQueue];
[handler addPayment:payment];
[self waitForExpectations:@[ expectation ] timeout:5];
XCTAssertEqual(tran.transactionState, SKPaymentTransactionStateRestored);
@@ -143,10 +149,12 @@
shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
return YES;
}
- updatedDownloads:nil];
- [queue addTransactionObserver:handler];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
+
SKPayment *payment =
[SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]];
+ [handler startObservingPaymentQueue];
[handler addPayment:payment];
[self waitForExpectations:@[ expectation ] timeout:5];
XCTAssertEqual(tran.transactionState, SKPaymentTransactionStatePurchasing);
@@ -171,10 +179,11 @@
shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
return YES;
}
- updatedDownloads:nil];
- [queue addTransactionObserver:handler];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
SKPayment *payment =
[SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]];
+ [handler startObservingPaymentQueue];
[handler addPayment:payment];
[self waitForExpectations:@[ expectation ] timeout:5];
XCTAssertEqual(tran.transactionState, SKPaymentTransactionStateDeferred);
@@ -201,12 +210,211 @@
shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
return YES;
}
- updatedDownloads:nil];
- [queue addTransactionObserver:handler];
+ updatedDownloads:nil
+ transactionCache:OCMClassMock(FIATransactionCache.class)];
SKPayment *payment =
[SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]];
+ [handler startObservingPaymentQueue];
[handler addPayment:payment];
[self waitForExpectations:@[ expectation ] timeout:5];
}
+- (void)testStartObservingPaymentQueueShouldNotProcessTransactionsWhenCacheIsEmpty {
+ FIATransactionCache *mockCache = OCMClassMock(FIATransactionCache.class);
+ FIAPaymentQueueHandler *handler =
+ [[FIAPaymentQueueHandler alloc] initWithQueue:[[SKPaymentQueueStub alloc] init]
+ transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTFail("transactionsUpdated callback should not be called when cache is empty.");
+ }
+ transactionRemoved:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTFail("transactionRemoved callback should not be called when cache is empty.");
+ }
+ restoreTransactionFailed:nil
+ restoreCompletedTransactionsFinished:nil
+ shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
+ return YES;
+ }
+ updatedDownloads:^(NSArray<SKDownload *> *_Nonnull downloads) {
+ XCTFail("updatedDownloads callback should not be called when cache is empty.");
+ }
+ transactionCache:mockCache];
+
+ [handler startObservingPaymentQueue];
+
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyUpdatedDownloads]);
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyRemovedTransactions]);
+}
+
+- (void)
+ testStartObservingPaymentQueueShouldNotProcessTransactionsWhenCacheContainsEmptyTransactionArrays {
+ FIATransactionCache *mockCache = OCMClassMock(FIATransactionCache.class);
+ FIAPaymentQueueHandler *handler =
+ [[FIAPaymentQueueHandler alloc] initWithQueue:[[SKPaymentQueueStub alloc] init]
+ transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTFail("transactionsUpdated callback should not be called when cache is empty.");
+ }
+ transactionRemoved:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTFail("transactionRemoved callback should not be called when cache is empty.");
+ }
+ restoreTransactionFailed:nil
+ restoreCompletedTransactionsFinished:nil
+ shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
+ return YES;
+ }
+ updatedDownloads:^(NSArray<SKDownload *> *_Nonnull downloads) {
+ XCTFail("updatedDownloads callback should not be called when cache is empty.");
+ }
+ transactionCache:mockCache];
+
+ OCMStub([mockCache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]).andReturn(@[]);
+ OCMStub([mockCache getObjectsForKey:TransactionCacheKeyUpdatedDownloads]).andReturn(@[]);
+ OCMStub([mockCache getObjectsForKey:TransactionCacheKeyRemovedTransactions]).andReturn(@[]);
+
+ [handler startObservingPaymentQueue];
+
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyUpdatedDownloads]);
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyRemovedTransactions]);
+}
+
+- (void)testStartObservingPaymentQueueShouldProcessTransactionsForItemsInCache {
+ XCTestExpectation *updateTransactionsExpectation =
+ [self expectationWithDescription:
+ @"transactionsUpdated callback should be called with one transaction."];
+ XCTestExpectation *removeTransactionsExpectation =
+ [self expectationWithDescription:
+ @"transactionsRemoved callback should be called with one transaction."];
+ XCTestExpectation *updateDownloadsExpectation =
+ [self expectationWithDescription:
+ @"downloadsUpdated callback should be called with one transaction."];
+ SKPaymentTransaction *mockTransaction = OCMClassMock(SKPaymentTransaction.class);
+ SKDownload *mockDownload = OCMClassMock(SKDownload.class);
+ FIATransactionCache *mockCache = OCMClassMock(FIATransactionCache.class);
+ FIAPaymentQueueHandler *handler =
+ [[FIAPaymentQueueHandler alloc] initWithQueue:[[SKPaymentQueueStub alloc] init]
+ transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTAssertEqualObjects(transactions, @[ mockTransaction ]);
+ [updateTransactionsExpectation fulfill];
+ }
+ transactionRemoved:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTAssertEqualObjects(transactions, @[ mockTransaction ]);
+ [removeTransactionsExpectation fulfill];
+ }
+ restoreTransactionFailed:nil
+ restoreCompletedTransactionsFinished:nil
+ shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
+ return YES;
+ }
+ updatedDownloads:^(NSArray<SKDownload *> *_Nonnull downloads) {
+ XCTAssertEqualObjects(downloads, @[ mockDownload ]);
+ [updateDownloadsExpectation fulfill];
+ }
+ transactionCache:mockCache];
+
+ OCMStub([mockCache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]).andReturn(@[
+ mockTransaction
+ ]);
+ OCMStub([mockCache getObjectsForKey:TransactionCacheKeyUpdatedDownloads]).andReturn(@[
+ mockDownload
+ ]);
+ OCMStub([mockCache getObjectsForKey:TransactionCacheKeyRemovedTransactions]).andReturn(@[
+ mockTransaction
+ ]);
+
+ [handler startObservingPaymentQueue];
+
+ [self waitForExpectations:@[
+ updateTransactionsExpectation, removeTransactionsExpectation, updateDownloadsExpectation
+ ]
+ timeout:5];
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyUpdatedTransactions]);
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyUpdatedDownloads]);
+ OCMVerify(times(1), [mockCache getObjectsForKey:TransactionCacheKeyRemovedTransactions]);
+ OCMVerify(times(1), [mockCache clear]);
+}
+
+- (void)testTransactionsShouldBeCachedWhenNotObserving {
+ SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init];
+ FIATransactionCache *mockCache = OCMClassMock(FIATransactionCache.class);
+ FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue
+ transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTFail("transactionsUpdated callback should not be called when cache is empty.");
+ }
+ transactionRemoved:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTFail("transactionRemoved callback should not be called when cache is empty.");
+ }
+ restoreTransactionFailed:nil
+ restoreCompletedTransactionsFinished:nil
+ shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
+ return YES;
+ }
+ updatedDownloads:^(NSArray<SKDownload *> *_Nonnull downloads) {
+ XCTFail("updatedDownloads callback should not be called when cache is empty.");
+ }
+ transactionCache:mockCache];
+
+ SKPayment *payment =
+ [SKPayment paymentWithProduct:[[SKProductStub alloc] initWithMap:self.productResponseMap]];
+ [handler addPayment:payment];
+
+ OCMVerify(times(1), [mockCache addObjects:[OCMArg any]
+ forKey:TransactionCacheKeyUpdatedTransactions]);
+ OCMVerify(never(), [mockCache addObjects:[OCMArg any]
+ forKey:TransactionCacheKeyUpdatedDownloads]);
+ OCMVerify(never(), [mockCache addObjects:[OCMArg any]
+ forKey:TransactionCacheKeyRemovedTransactions]);
+}
+
+- (void)testTransactionsShouldNotBeCachedWhenObserving {
+ XCTestExpectation *updateTransactionsExpectation =
+ [self expectationWithDescription:
+ @"transactionsUpdated callback should be called with one transaction."];
+ XCTestExpectation *removeTransactionsExpectation =
+ [self expectationWithDescription:
+ @"transactionsRemoved callback should be called with one transaction."];
+ XCTestExpectation *updateDownloadsExpectation =
+ [self expectationWithDescription:
+ @"downloadsUpdated callback should be called with one transaction."];
+ SKPaymentTransaction *mockTransaction = OCMClassMock(SKPaymentTransaction.class);
+ SKDownload *mockDownload = OCMClassMock(SKDownload.class);
+ SKPaymentQueueStub *queue = [[SKPaymentQueueStub alloc] init];
+ queue.testState = SKPaymentTransactionStatePurchased;
+ FIATransactionCache *mockCache = OCMClassMock(FIATransactionCache.class);
+ FIAPaymentQueueHandler *handler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue
+ transactionsUpdated:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTAssertEqualObjects(transactions, @[ mockTransaction ]);
+ [updateTransactionsExpectation fulfill];
+ }
+ transactionRemoved:^(NSArray<SKPaymentTransaction *> *_Nonnull transactions) {
+ XCTAssertEqualObjects(transactions, @[ mockTransaction ]);
+ [removeTransactionsExpectation fulfill];
+ }
+ restoreTransactionFailed:nil
+ restoreCompletedTransactionsFinished:nil
+ shouldAddStorePayment:^BOOL(SKPayment *_Nonnull payment, SKProduct *_Nonnull product) {
+ return YES;
+ }
+ updatedDownloads:^(NSArray<SKDownload *> *_Nonnull downloads) {
+ XCTAssertEqualObjects(downloads, @[ mockDownload ]);
+ [updateDownloadsExpectation fulfill];
+ }
+ transactionCache:mockCache];
+
+ [handler startObservingPaymentQueue];
+ [handler paymentQueue:queue updatedTransactions:@[ mockTransaction ]];
+ [handler paymentQueue:queue removedTransactions:@[ mockTransaction ]];
+ [handler paymentQueue:queue updatedDownloads:@[ mockDownload ]];
+
+ [self waitForExpectations:@[
+ updateTransactionsExpectation, removeTransactionsExpectation, updateDownloadsExpectation
+ ]
+ timeout:5];
+ OCMVerify(never(), [mockCache addObjects:[OCMArg any]
+ forKey:TransactionCacheKeyUpdatedTransactions]);
+ OCMVerify(never(), [mockCache addObjects:[OCMArg any]
+ forKey:TransactionCacheKeyUpdatedDownloads]);
+ OCMVerify(never(), [mockCache addObjects:[OCMArg any]
+ forKey:TransactionCacheKeyRemovedTransactions]);
+}
@end
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.h b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.h
index 8019831..bb074aa 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.h
+++ b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.h
@@ -4,6 +4,7 @@
#import <Foundation/Foundation.h>
#import <StoreKit/StoreKit.h>
+#import "FIATransactionCache.h"
@class SKPaymentTransaction;
@@ -21,6 +22,27 @@
@property(NS_NONATOMIC_IOSONLY, weak, nullable) id<SKPaymentQueueDelegate> delegate API_AVAILABLE(
ios(13.0), macos(10.15), watchos(6.2));
+/// Creates a new FIAPaymentQueueHandler initialized with an empty
+/// FIATransactionCache.
+///
+/// @param queue The SKPaymentQueue instance connected to the App Store and
+/// responsible for processing transactions.
+/// @param transactionsUpdated Callback method that is called each time the App
+/// Store indicates transactions are updated.
+/// @param transactionsRemoved Callback method that is called each time the App
+/// Store indicates transactions are removed.
+/// @param restoreTransactionFailed Callback method that is called each time
+/// the App Store indicates transactions failed
+/// to restore.
+/// @param restoreCompletedTransactionsFinished Callback method that is called
+/// each time the App Store
+/// indicates restoring of
+/// transactions has finished.
+/// @param shouldAddStorePayment Callback method that is called each time an
+/// in-app purchase has been initiated from the
+/// App Store.
+/// @param updatedDownloads Callback method that is called each time the App
+/// Store indicates downloads are updated.
- (instancetype)initWithQueue:(nonnull SKPaymentQueue *)queue
transactionsUpdated:(nullable TransactionsUpdated)transactionsUpdated
transactionRemoved:(nullable TransactionsRemoved)transactionsRemoved
@@ -28,7 +50,57 @@
restoreCompletedTransactionsFinished:
(nullable RestoreCompletedTransactionsFinished)restoreCompletedTransactionsFinished
shouldAddStorePayment:(nullable ShouldAddStorePayment)shouldAddStorePayment
- updatedDownloads:(nullable UpdatedDownloads)updatedDownloads;
+ updatedDownloads:(nullable UpdatedDownloads)updatedDownloads
+ DEPRECATED_MSG_ATTRIBUTE(
+ "Use the "
+ "'initWithQueue:transactionsUpdated:transactionsRemoved:restoreTransactionsFinished:"
+ "shouldAddStorePayment:updatedDownloads:transactionCache:' message instead.");
+
+/// Creates a new FIAPaymentQueueHandler.
+///
+/// The "transactionsUpdated", "transactionsRemoved" and "updatedDownloads"
+/// callbacks are only called while actively observing transactions. To start
+/// observing transactions send the "startObservingPaymentQueue" message.
+/// Sending the "stopObservingPaymentQueue" message will stop actively
+/// observing transactions. When transactions are not observed they are cached
+/// to the "transactionCache" and will be delivered via the
+/// "transactionsUpdated", "transactionsRemoved" and "updatedDownloads"
+/// callbacks as soon as the "startObservingPaymentQueue" message arrives.
+///
+/// Note: cached transactions that are not processed when the application is
+/// killed will be delivered again by the App Store as soon as the application
+/// starts again.
+///
+/// @param queue The SKPaymentQueue instance connected to the App Store and
+/// responsible for processing transactions.
+/// @param transactionsUpdated Callback method that is called each time the App
+/// Store indicates transactions are updated.
+/// @param transactionsRemoved Callback method that is called each time the App
+/// Store indicates transactions are removed.
+/// @param restoreTransactionFailed Callback method that is called each time
+/// the App Store indicates transactions failed
+/// to restore.
+/// @param restoreCompletedTransactionsFinished Callback method that is called
+/// each time the App Store
+/// indicates restoring of
+/// transactions has finished.
+/// @param shouldAddStorePayment Callback method that is called each time an
+/// in-app purchase has been initiated from the
+/// App Store.
+/// @param updatedDownloads Callback method that is called each time the App
+/// Store indicates downloads are updated.
+/// @param transactionCache An empty [FIATransactionCache] instance that is
+/// responsible for keeping track of transactions that
+/// arrive when not actively observing transactions.
+- (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
+ transactionCache:(nonnull FIATransactionCache *)transactionCache;
// 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;
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.m b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.m
index 2166795..59fdced 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.m
+++ b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIAPaymentQueueHandler.m
@@ -4,18 +4,49 @@
#import "FIAPaymentQueueHandler.h"
#import "FIAPPaymentQueueDelegate.h"
+#import "FIATransactionCache.h"
@interface FIAPaymentQueueHandler ()
+/// The SKPaymentQueue instance connected to the App Store and responsible for processing
+/// transactions.
@property(strong, nonatomic) SKPaymentQueue *queue;
+
+/// Callback method that is called each time the App Store indicates transactions are updated.
@property(nullable, copy, nonatomic) TransactionsUpdated transactionsUpdated;
+
+/// Callback method that is called each time the App Store indicates transactions are removed.
@property(nullable, copy, nonatomic) TransactionsRemoved transactionsRemoved;
+
+/// Callback method that is called each time the App Store indicates transactions failed to restore.
@property(nullable, copy, nonatomic) RestoreTransactionFailed restoreTransactionFailed;
+
+/// Callback method that is called each time the App Store indicates restoring of transactions has
+/// finished.
@property(nullable, copy, nonatomic)
RestoreCompletedTransactionsFinished paymentQueueRestoreCompletedTransactionsFinished;
+
+/// Callback method that is called each time an in-app purchase has been initiated from the App
+/// Store.
@property(nullable, copy, nonatomic) ShouldAddStorePayment shouldAddStorePayment;
+
+/// Callback method that is called each time the App Store indicates downloads are updated.
@property(nullable, copy, nonatomic) UpdatedDownloads updatedDownloads;
+/// The transaction cache responsible for caching transactions.
+///
+/// Keeps track of transactions that arrive when the Flutter client is not
+/// actively observing for transactions.
+@property(strong, nonatomic, nonnull) FIATransactionCache *transactionCache;
+
+/// Indicates if the Flutter client is observing transactions.
+///
+/// When the client is not observing, transactions are cached and send to the
+/// client as soon as it starts observing. The Flutter client can start
+/// observing by sending a startObservingPaymentQueue message and stop by
+/// sending a stopObservingPaymentQueue message.
+@property(atomic, assign, readwrite, getter=isObservingTransactions) BOOL observingTransactions;
+
@end
@implementation FIAPaymentQueueHandler
@@ -28,6 +59,25 @@
(nullable RestoreCompletedTransactionsFinished)restoreCompletedTransactionsFinished
shouldAddStorePayment:(nullable ShouldAddStorePayment)shouldAddStorePayment
updatedDownloads:(nullable UpdatedDownloads)updatedDownloads {
+ return [[FIAPaymentQueueHandler alloc] initWithQueue:queue
+ transactionsUpdated:transactionsUpdated
+ transactionRemoved:transactionsRemoved
+ restoreTransactionFailed:restoreTransactionFailed
+ restoreCompletedTransactionsFinished:restoreCompletedTransactionsFinished
+ shouldAddStorePayment:shouldAddStorePayment
+ updatedDownloads:updatedDownloads
+ transactionCache:[[FIATransactionCache alloc] init]];
+}
+
+- (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
+ transactionCache:(nonnull FIATransactionCache *)transactionCache {
self = [super init];
if (self) {
_queue = queue;
@@ -37,7 +87,9 @@
_paymentQueueRestoreCompletedTransactionsFinished = restoreCompletedTransactionsFinished;
_shouldAddStorePayment = shouldAddStorePayment;
_updatedDownloads = updatedDownloads;
+ _transactionCache = transactionCache;
+ [_queue addTransactionObserver:self];
if (@available(iOS 13.0, macOS 10.15, *)) {
queue.delegate = self.delegate;
}
@@ -46,11 +98,43 @@
}
- (void)startObservingPaymentQueue {
- [_queue addTransactionObserver:self];
+ self.observingTransactions = YES;
+
+ [self processCachedTransactions];
}
- (void)stopObservingPaymentQueue {
- [_queue removeTransactionObserver:self];
+ // When the client stops observing transaction, the transaction observer is
+ // not removed from the SKPaymentQueue. The FIAPaymentQueueHandler will cache
+ // trasnactions in memory when the client is not observing, allowing the app
+ // to process these transactions if it starts observing again during the same
+ // lifetime of the app.
+ //
+ // If the app is killed, cached transactions will be removed from memory;
+ // however, the App Store will re-deliver the transactions as soon as the app
+ // is started again, since the cached transactions have not been acknowledged
+ // by the client (by sending the `finishTransaction` message).
+ self.observingTransactions = NO;
+}
+
+- (void)processCachedTransactions {
+ NSArray *cachedObjects =
+ [self.transactionCache getObjectsForKey:TransactionCacheKeyUpdatedTransactions];
+ if (cachedObjects.count != 0) {
+ self.transactionsUpdated(cachedObjects);
+ }
+
+ cachedObjects = [self.transactionCache getObjectsForKey:TransactionCacheKeyUpdatedDownloads];
+ if (cachedObjects.count != 0) {
+ self.updatedDownloads(cachedObjects);
+ }
+
+ cachedObjects = [self.transactionCache getObjectsForKey:TransactionCacheKeyRemovedTransactions];
+ if (cachedObjects.count != 0) {
+ self.transactionsRemoved(cachedObjects);
+ }
+
+ [self.transactionCache clear];
}
- (BOOL)addPayment:(SKPayment *)payment {
@@ -93,6 +177,11 @@
// state of transactions and finish as appropriate.
- (void)paymentQueue:(SKPaymentQueue *)queue
updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
+ if (!self.observingTransactions) {
+ [_transactionCache addObjects:transactions forKey:TransactionCacheKeyUpdatedTransactions];
+ return;
+ }
+
// notify dart through callbacks.
self.transactionsUpdated(transactions);
}
@@ -100,6 +189,10 @@
// Sent when transactions are removed from the queue (via finishTransaction:).
- (void)paymentQueue:(SKPaymentQueue *)queue
removedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
+ if (!self.observingTransactions) {
+ [_transactionCache addObjects:transactions forKey:TransactionCacheKeyRemovedTransactions];
+ return;
+ }
self.transactionsRemoved(transactions);
}
@@ -118,6 +211,10 @@
// Sent when the download state has changed.
- (void)paymentQueue:(SKPaymentQueue *)queue updatedDownloads:(NSArray<SKDownload *> *)downloads {
+ if (!self.observingTransactions) {
+ [_transactionCache addObjects:downloads forKey:TransactionCacheKeyUpdatedDownloads];
+ return;
+ }
self.updatedDownloads(downloads);
}
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIATransactionCache.h b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIATransactionCache.h
new file mode 100644
index 0000000..dea3c2d
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIATransactionCache.h
@@ -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.
+
+NS_ASSUME_NONNULL_BEGIN
+
+typedef NS_ENUM(NSUInteger, TransactionCacheKey) {
+ TransactionCacheKeyUpdatedDownloads,
+ TransactionCacheKeyUpdatedTransactions,
+ TransactionCacheKeyRemovedTransactions
+};
+
+@interface FIATransactionCache : NSObject
+
+/// Adds objects to the transaction cache.
+///
+/// If the cache already contains an array of objects on the specified key, the supplied
+/// array will be appended to the existing array.
+- (void)addObjects:(NSArray *)objects forKey:(TransactionCacheKey)key;
+
+/// Gets the array of objects stored at the given key.
+///
+/// If there are no objects associated with the given key nil is returned.
+- (NSArray *)getObjectsForKey:(TransactionCacheKey)key;
+
+/// Removes all objects from the transaction cache.
+- (void)clear;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIATransactionCache.m b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIATransactionCache.m
new file mode 100644
index 0000000..f80b9c4
--- /dev/null
+++ b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/FIATransactionCache.m
@@ -0,0 +1,40 @@
+// 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 "FIATransactionCache.h"
+
+@interface FIATransactionCache ()
+
+/// A NSMutableDictionary storing the objects that are cached.
+@property(nonatomic, strong, nonnull) NSMutableDictionary *cache;
+
+@end
+
+@implementation FIATransactionCache
+
+- (instancetype)init {
+ self = [super init];
+ if (self) {
+ self.cache = [[NSMutableDictionary alloc] init];
+ }
+
+ return self;
+}
+
+- (void)addObjects:(NSArray *)objects forKey:(TransactionCacheKey)key {
+ NSArray *cachedObjects = self.cache[@(key)];
+
+ self.cache[@(key)] =
+ cachedObjects ? [cachedObjects arrayByAddingObjectsFromArray:objects] : objects;
+}
+
+- (NSArray *)getObjectsForKey:(TransactionCacheKey)key {
+ return self.cache[@(key)];
+}
+
+- (void)clear {
+ [self.cache removeAllObjects];
+}
+
+@end
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/InAppPurchasePlugin.m
index 661f57f..a580a46 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/InAppPurchasePlugin.m
+++ b/packages/in_app_purchase/in_app_purchase_storekit/ios/Classes/InAppPurchasePlugin.m
@@ -79,7 +79,8 @@
}
updatedDownloads:^void(NSArray<SKDownload *> *_Nonnull downloads) {
[weakSelf updatedDownloads:downloads];
- }];
+ }
+ transactionCache:[[FIATransactionCache alloc] init]];
_transactionObserverCallbackChannel =
[FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/in_app_purchase"
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 7e70168..51b5ce7 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 @@
description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework.
repository: https://github.com/flutter/plugins/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.0+2
+version: 0.3.0+3
environment:
sdk: ">=2.14.0 <3.0.0"