diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 4b2d8ce1dc24..c4c4eb05cecd 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,7 +1,11 @@ +## 0.1.1 + +* Added support to register a `SKPaymentQueueDelegateWrapper` and handle changes to active subscriptions accordingly (see also Store Kit's [SKPaymentQueueDelegate](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc)). + ## 0.1.0+2 -* Changed the iOS payment queue handler in such a way that it only adds a listener to the SKPaymentQueue when there - is a listener to the Dart purchaseStream. +* Changed the iOS payment queue handler in such a way that it only adds a listener to the `SKPaymentQueue` when there + is a listener to the Dart `purchaseStream`. ## 0.1.0+1 diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile index ae8750242a6e..5200b9fa5045 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile @@ -29,12 +29,12 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - + target 'RunnerTests' do inherit! :search_paths # Matches in_app_purchase test_spec dependency. - pod 'OCMock','3.5' + pod 'OCMock', '~> 3.6' end end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj index 590b07f0d385..61a5da696986 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 688DE35121F2A5A100EA2684 /* TranslatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 688DE35021F2A5A100EA2684 /* TranslatorTests.m */; }; 6896B34621E9363700D37AEF /* ProductRequestHandlerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */; }; 6896B34C21EEB4B800D37AEF /* Stubs.m in Sources */ = {isa = PBXBuildFile; fileRef = 6896B34B21EEB4B800D37AEF /* Stubs.m */; }; + 7E34217B7715B1918134647A /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 18D02AB334F1C07BB9A4374A /* libPods-RunnerTests.a */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -20,7 +21,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A5279298219369C600FF69E6 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5279297219369C600FF69E6 /* StoreKit.framework */; }; A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */; }; - AB7252348F077C046D6617D3 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */; }; + F67646F82681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */; }; F78AF3142342BC89008449C7 /* PaymentQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F78AF3132342BC89008449C7 /* PaymentQueueTests.m */; }; /* End PBXBuildFile section */ @@ -48,14 +49,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 10B860DFD91A1DF639D7BE1D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 1630769A874F9381BC761FE1 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 18D02AB334F1C07BB9A4374A /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 2550EB3A5A3E749A54ADCA2D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 688DE35021F2A5A100EA2684 /* TranslatorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TranslatorTests.m; sourceTree = ""; }; 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ProductRequestHandlerTests.m; sourceTree = ""; }; 6896B34A21EEB4B800D37AEF /* Stubs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Stubs.h; sourceTree = ""; }; @@ -71,11 +71,13 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9D681E092EB0D20D652F69FC /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; A5279297219369C600FF69E6 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; A59001A421E69658004A3E5E /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InAppPurchasePluginTests.m; sourceTree = ""; }; A59001A821E69658004A3E5E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; + F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIAPPaymentQueueDeleteTests.m; sourceTree = ""; }; F6E5D5F926131C4800C68BED /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; F78AF3132342BC89008449C7 /* PaymentQueueTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PaymentQueueTests.m; sourceTree = ""; }; /* End PBXFileReference section */ @@ -94,7 +96,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - AB7252348F077C046D6617D3 /* libPods-RunnerTests.a in Frameworks */, + 7E34217B7715B1918134647A /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -106,8 +108,8 @@ children = ( E4F9651425A612301059769C /* Pods-Runner.debug.xcconfig */, 2550EB3A5A3E749A54ADCA2D /* Pods-Runner.release.xcconfig */, - 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */, - 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */, + 9D681E092EB0D20D652F69FC /* Pods-RunnerTests.debug.xcconfig */, + 10B860DFD91A1DF639D7BE1D /* Pods-RunnerTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -187,6 +189,7 @@ 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */, F78AF3132342BC89008449C7 /* PaymentQueueTests.m */, 688DE35021F2A5A100EA2684 /* TranslatorTests.m */, + F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */, ); path = RunnerTests; sourceTree = ""; @@ -196,7 +199,7 @@ children = ( A5279297219369C600FF69E6 /* StoreKit.framework */, 1630769A874F9381BC761FE1 /* libPods-Runner.a */, - 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */, + 18D02AB334F1C07BB9A4374A /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -229,7 +232,7 @@ isa = PBXNativeTarget; buildConfigurationList = A59001AD21E69658004A3E5E /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 321E2F5767F55B0A360AA77E /* [CP] Check Pods Manifest.lock */, + 95C7A5986B77A8DF76F6DF3A /* [CP] Check Pods Manifest.lock */, A59001A021E69658004A3E5E /* Sources */, A59001A121E69658004A3E5E /* Frameworks */, A59001A221E69658004A3E5E /* Resources */, @@ -310,41 +313,41 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 321E2F5767F55B0A360AA77E /* [CP] Check Pods Manifest.lock */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( ); + name = "Thin Binary"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 95C7A5986B77A8DF76F6DF3A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Thin Binary"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -400,6 +403,7 @@ buildActionMask = 2147483647; files = ( F78AF3142342BC89008449C7 /* PaymentQueueTests.m in Sources */, + F67646F82681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m in Sources */, 6896B34621E9363700D37AEF /* ProductRequestHandlerTests.m in Sources */, 688DE35121F2A5A100EA2684 /* TranslatorTests.m in Sources */, A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */, @@ -593,7 +597,7 @@ }; A59001AB21E69658004A3E5E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 9D681E092EB0D20D652F69FC /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -616,7 +620,7 @@ }; A59001AC21E69658004A3E5E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 10B860DFD91A1DF639D7BE1D /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit index 4958a846e67d..b98fefb68a95 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit @@ -1,4 +1,8 @@ { + "identifier" : "6073E9A3", + "nonRenewingSubscriptions" : [ + + ], "products" : [ { "displayPrice" : "0.99", @@ -46,7 +50,7 @@ "adHocOffers" : [ ], - "displayPrice" : "3.99", + "displayPrice" : "4.99", "familyShareable" : false, "groupNumber" : 1, "internalID" : "922EB597", @@ -59,7 +63,7 @@ } ], "productID" : "subscription_silver", - "recurringSubscriptionPeriod" : "P1M", + "recurringSubscriptionPeriod" : "P1W", "referenceName" : "subscription_silver", "subscriptionGroupID" : "D0FEE8D8", "type" : "RecurringSubscription" @@ -91,6 +95,6 @@ ], "version" : { "major" : 1, - "minor" : 0 + "minor" : 1 } } diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m new file mode 100644 index 000000000000..810e1fafe11a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m @@ -0,0 +1,120 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import "FIAObjectTranslator.h" +#import "FIAPaymentQueueHandler.h" +#import "Stubs.h" + +@import in_app_purchase_ios; + +API_AVAILABLE(ios(13.0)) +@interface FIAPPaymentQueueDelegateTests : XCTestCase + +@property(strong, nonatomic) FlutterMethodChannel *channel; +@property(strong, nonatomic) SKPaymentTransaction *transaction; +@property(strong, nonatomic) SKStorefront *storefront; + +@end + +@implementation FIAPPaymentQueueDelegateTests + +- (void)setUp { + self.channel = OCMClassMock(FlutterMethodChannel.class); + + NSDictionary *transactionMap = @{ + @"transactionIdentifier" : [NSNull null], + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + @"originalTransaction" : [NSNull null], + }; + self.transaction = [[SKPaymentTransactionStub alloc] initWithMap:transactionMap]; + + NSDictionary *storefrontMap = @{ + @"countryCode" : @"USA", + @"identifier" : @"unique_identifier", + }; + self.storefront = [[SKStorefrontStub alloc] initWithMap:storefrontMap]; +} + +- (void)tearDown { + self.channel = nil; +} + +- (void)testShouldContinueTransaction { + if (@available(iOS 13.0, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel + invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:self.storefront + andSKPaymentTransaction:self.transaction] + result:([OCMArg invokeBlockWithArgs:[NSNumber numberWithBool:NO], nil])]); + + BOOL shouldContinue = [delegate paymentQueue:OCMClassMock(SKPaymentQueue.class) + shouldContinueTransaction:self.transaction + inStorefront:self.storefront]; + + XCTAssertFalse(shouldContinue); + } +} + +- (void)testShouldContinueTransaction_should_default_to_yes { + if (@available(iOS 13.0, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:self.storefront + andSKPaymentTransaction:self.transaction] + result:[OCMArg any]]); + + BOOL shouldContinue = [delegate paymentQueue:OCMClassMock(SKPaymentQueue.class) + shouldContinueTransaction:self.transaction + inStorefront:self.storefront]; + + XCTAssertTrue(shouldContinue); + } +} + +- (void)testShouldShowPriceConsentIfNeeded { + if (@available(iOS 13.4, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel + invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:([OCMArg invokeBlockWithArgs:[NSNumber numberWithBool:NO], nil])]); + + BOOL shouldShow = + [delegate paymentQueueShouldShowPriceConsent:OCMClassMock(SKPaymentQueue.class)]; + + XCTAssertFalse(shouldShow); + } +} + +- (void)testShouldShowPriceConsentIfNeeded_should_default_to_yes { + if (@available(iOS 13.4, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:[OCMArg any]]); + + BOOL shouldShow = + [delegate paymentQueueShouldShowPriceConsent:OCMClassMock(SKPaymentQueue.class)]; + + XCTAssertTrue(shouldShow); + } +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m index 241ea0d5cb0d..045abcdea922 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m @@ -343,4 +343,81 @@ - (void)testStartAndStopObservingPaymentQueue { XCTAssertNil(queue.observer); } +- (void)testRegisterPaymentQueueDelegate { + if (@available(iOS 13, *)) { + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue registerDelegate]" + arguments:nil]; + + self.plugin.paymentQueueHandler = + [[FIAPaymentQueueHandler alloc] initWithQueue:[SKPaymentQueueStub new] + transactionsUpdated:nil + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:nil + updatedDownloads:nil]; + + // Verify the delegate is nil before we register one. + XCTAssertNil(self.plugin.paymentQueueHandler.delegate); + + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + + // Verify the delegate is not nil after we registered one. + XCTAssertNotNil(self.plugin.paymentQueueHandler.delegate); + } +} + +- (void)testRemovePaymentQueueDelegate { + if (@available(iOS 13, *)) { + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue removeDelegate]" + arguments:nil]; + + self.plugin.paymentQueueHandler = + [[FIAPaymentQueueHandler alloc] initWithQueue:[SKPaymentQueueStub new] + transactionsUpdated:nil + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:nil + updatedDownloads:nil]; + self.plugin.paymentQueueHandler.delegate = OCMProtocolMock(@protocol(SKPaymentQueueDelegate)); + + // Verify the delegate is not nil before removing it. + XCTAssertNotNil(self.plugin.paymentQueueHandler.delegate); + + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + + // Verify the delegate is nill after removing it. + XCTAssertNil(self.plugin.paymentQueueHandler.delegate); + } +} + +- (void)testShowPriceConsentIfNeeded { + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue showPriceConsentIfNeeded]" + arguments:nil]; + + FIAPaymentQueueHandler* mockQueueHandler = OCMClassMock(FIAPaymentQueueHandler.class); + self.plugin.paymentQueueHandler = mockQueueHandler; + + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wpartial-availability" + if (@available(iOS 13.4, *)) { + OCMVerify(times(1), [mockQueueHandler showPriceConsentIfNeeded]); + } else { + OCMVerify(never(), [mockQueueHandler showPriceConsentIfNeeded]); + } +#pragma clang diagnostic pop +} + @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h index 687118febb29..7b6842da4c77 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h @@ -60,4 +60,9 @@ API_AVAILABLE(ios(11.2), macos(10.13.2)) - (instancetype)initWithFailureError:(NSError *)error; @end +API_AVAILABLE(ios(13.0), macos(10.15)) +@interface SKStorefrontStub : SKStorefront +- (instancetype)initWithMap:(NSDictionary *)map; +@end + NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m index 8af326a48722..a57831c61da5 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m @@ -290,3 +290,17 @@ - (void)start { } @end + +@implementation SKStorefrontStub + +- (instancetype)initWithMap:(NSDictionary *)map { + self = [super init]; + if (self) { + // Set stub values + [self setValue:map[@"countryCode"] forKey:@"countryCode"]; + [self setValue:map[@"identifier"] forKey:@"identifier"]; + } + return self; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m index 385d29140e49..42c51b846857 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m @@ -17,6 +17,8 @@ @interface TranslatorTest : XCTestCase @property(strong, nonatomic) NSDictionary *transactionMap; @property(strong, nonatomic) NSDictionary *errorMap; @property(strong, nonatomic) NSDictionary *localeMap; +@property(strong, nonatomic) NSDictionary *storefrontMap; +@property(strong, nonatomic) NSDictionary *storefrontAndPaymentTransactionMap; @end @@ -84,6 +86,15 @@ - (void)setUp { @"key" : @"value", } }; + self.storefrontMap = @{ + @"countryCode" : @"USA", + @"identifier" : @"unique_identifier", + }; + + self.storefrontAndPaymentTransactionMap = @{ + @"storefront" : self.storefrontMap, + @"transaction" : self.transactionMap, + }; } - (void)testSKProductSubscriptionPeriodStubToMap { @@ -144,4 +155,23 @@ - (void)testLocaleToMap { } } +- (void)testSKStorefrontToMap { + if (@available(iOS 13.0, *)) { + SKStorefront *storefront = [[SKStorefrontStub alloc] initWithMap:self.storefrontMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKStorefront:storefront]; + XCTAssertEqualObjects(map, self.storefrontMap); + } +} + +- (void)testSKStorefrontAndSKPaymentTransactionToMap { + if (@available(iOS 13.0, *)) { + SKStorefront *storefront = [[SKStorefrontStub alloc] initWithMap:self.storefrontMap]; + SKPaymentTransaction *transaction = + [[SKPaymentTransactionStub alloc] initWithMap:self.transactionMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKStorefront:storefront + andSKPaymentTransaction:transaction]; + XCTAssertEqualObjects(map, self.storefrontAndPaymentTransactionMap); + } +} + @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart b/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart new file mode 100644 index 000000000000..dfebdf9cdf98 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +/// Example implementation of the +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + return true; + } + + @override + bool shouldShowPriceConsent() { + return false; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart index 5452f5a0ee83..19884745bce8 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_ios_example/example_payment_queue_delegate.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import 'consumable_store.dart'; @@ -40,6 +41,9 @@ class _MyApp extends StatefulWidget { class _MyAppState extends State<_MyApp> { final InAppPurchaseIosPlatform _iapIosPlatform = InAppPurchasePlatform.instance as InAppPurchaseIosPlatform; + final InAppPurchaseIosPlatformAddition _iapIosPlatformAddition = + InAppPurchasePlatformAddition.instance + as InAppPurchaseIosPlatformAddition; late StreamSubscription> _subscription; List _notFoundIds = []; List _products = []; @@ -61,6 +65,10 @@ class _MyAppState extends State<_MyApp> { }, onError: (error) { // handle error here. }); + + // Register the example payment queue delegate + _iapIosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); + initStoreInfo(); super.initState(); } @@ -241,7 +249,11 @@ class _MyAppState extends State<_MyApp> { productDetails.description, ), trailing: previousPurchase != null - ? Icon(Icons.check) + ? IconButton( + onPressed: () { + _iapIosPlatformAddition.showPriceConsentIfNeeded(); + }, + icon: Icon(Icons.upgrade)) : TextButton( child: Text(productDetails.price), style: TextButton.styleFrom( diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h index 2d0187e88aed..95a5edc245dc 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h @@ -9,26 +9,44 @@ NS_ASSUME_NONNULL_BEGIN @interface FIAObjectTranslator : NSObject +// Converts an instance of SKProduct into a dictionary. + (NSDictionary *)getMapFromSKProduct:(SKProduct *)product; +// Converts an instance of SKProductSubscriptionPeriod into a dictionary. + (NSDictionary *)getMapFromSKProductSubscriptionPeriod:(SKProductSubscriptionPeriod *)period API_AVAILABLE(ios(11.2)); +// Converts an instance of SKProductDiscount into a dictionary. + (NSDictionary *)getMapFromSKProductDiscount:(SKProductDiscount *)discount API_AVAILABLE(ios(11.2)); +// Converts an instance of SKProductsResponse into a dictionary. + (NSDictionary *)getMapFromSKProductsResponse:(SKProductsResponse *)productResponse; +// Converts an instance of SKPayment into a dictionary. + (NSDictionary *)getMapFromSKPayment:(SKPayment *)payment; +// Converts an instance of NSLocale into a dictionary. + (NSDictionary *)getMapFromNSLocale:(NSLocale *)locale; +// Creates an instance of the SKMutablePayment class based on the supplied dictionary. + (SKMutablePayment *)getSKMutablePaymentFromMap:(NSDictionary *)map; +// Converts an instance of SKPaymentTransaction into a dictionary. + (NSDictionary *)getMapFromSKPaymentTransaction:(SKPaymentTransaction *)transaction; +// Converts an instance of NSError into a dictionary. + (NSDictionary *)getMapFromNSError:(NSError *)error; +// Converts an instance of SKStorefront into a dictionary. ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront + API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); + +// Converts the supplied instances of SKStorefront and SKPaymentTransaction into a dictionary. ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront + andSKPaymentTransaction:(SKPaymentTransaction *)transaction + API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); + @end ; diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m index 5d6e0a244a96..30b0b812da15 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m @@ -169,4 +169,31 @@ + (NSDictionary *)getMapFromNSError:(NSError *)error { return @{@"code" : @(error.code), @"domain" : error.domain ?: @"", @"userInfo" : userInfo}; } ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront { + if (!storefront) { + return nil; + } + + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"countryCode" : storefront.countryCode, + @"identifier" : storefront.identifier + }]; + + return map; +} + ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront + andSKPaymentTransaction:(SKPaymentTransaction *)transaction { + if (!storefront || !transaction) { + return nil; + } + + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"storefront" : [FIAObjectTranslator getMapFromSKStorefront:storefront], + @"transaction" : [FIAObjectTranslator getMapFromSKPaymentTransaction:transaction] + }]; + + return map; +} + @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h new file mode 100644 index 000000000000..a6c91fa9e6b6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h @@ -0,0 +1,16 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +API_AVAILABLE(ios(13)) +@interface FIAPPaymentQueueDelegate : NSObject +- (id)initWithMethodChannel:(FlutterMethodChannel *)methodChannel; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m new file mode 100644 index 000000000000..1056086030a5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m @@ -0,0 +1,78 @@ +// 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 "FIAPPaymentQueueDelegate.h" +#import "FIAObjectTranslator.h" + +@interface FIAPPaymentQueueDelegate () + +@property(strong, nonatomic, readonly) FlutterMethodChannel *callbackChannel; + +@end + +@implementation FIAPPaymentQueueDelegate + +- (id)initWithMethodChannel:(FlutterMethodChannel *)methodChannel { + self = [super init]; + if (self) { + _callbackChannel = methodChannel; + } + + return self; +} + +- (BOOL)paymentQueue:(SKPaymentQueue *)paymentQueue + shouldContinueTransaction:(SKPaymentTransaction *)transaction + inStorefront:(SKStorefront *)newStorefront { + // Default return value for this method is true (see + // https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc) + __block BOOL shouldContinue = YES; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [self.callbackChannel invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:newStorefront + andSKPaymentTransaction:transaction] + result:^(id _Nullable result) { + // When result is a valid instance of NSNumber use it to determine + // if the transaction should continue. Otherwise use the default + // value. + if (result && [result isKindOfClass:[NSNumber class]]) { + shouldContinue = [(NSNumber *)result boolValue]; + } + + dispatch_semaphore_signal(semaphore); + }]; + + // The client should respond within 1 second otherwise continue + // with default value. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + + return shouldContinue; +} + +- (BOOL)paymentQueueShouldShowPriceConsent:(SKPaymentQueue *)paymentQueue { + // Default return value for this method is true (see + // https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc) + __block BOOL shouldShowPriceConsent = YES; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [self.callbackChannel invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:^(id _Nullable result) { + // When result is a valid instance of NSNumber use it to determine + // if the transaction should continue. Otherwise use the default + // value. + if (result && [result isKindOfClass:[NSNumber class]]) { + shouldShowPriceConsent = [(NSNumber *)result boolValue]; + } + + dispatch_semaphore_signal(semaphore); + }]; + + // The client should respond within 1 second otherwise continue + // with default value. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + + return shouldShowPriceConsent; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h index 30865b2c3598..8019831d6355 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h @@ -18,6 +18,9 @@ typedef void (^UpdatedDownloads)(NSArray *downloads); @interface FIAPaymentQueueHandler : NSObject +@property(NS_NONATOMIC_IOSONLY, weak, nullable) id delegate API_AVAILABLE( + ios(13.0), macos(10.15), watchos(6.2)); + - (instancetype)initWithQueue:(nonnull SKPaymentQueue *)queue transactionsUpdated:(nullable TransactionsUpdated)transactionsUpdated transactionRemoved:(nullable TransactionsRemoved)transactionsRemoved @@ -43,6 +46,15 @@ typedef void (^UpdatedDownloads)(NSArray *downloads); // @return whether "addPayment" was successful. - (BOOL)addPayment:(SKPayment *)payment; +// Displays the price consent sheet. +// +// The price consent sheet is only displayed when the following +// it true: +// - You have increased the price of the subscription in App Store Connect. +// - The subscriber has not yet responded to a price consent query. +// Otherwise the method has no effect. +- (void)showPriceConsentIfNeeded API_AVAILABLE(ios(13.4)); + @end NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m index 20ccbc5adb48..21667954cf8d 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m @@ -3,6 +3,7 @@ // found in the LICENSE file. #import "FIAPaymentQueueHandler.h" +#import "FIAPPaymentQueueDelegate.h" @interface FIAPaymentQueueHandler () @@ -36,6 +37,10 @@ - (instancetype)initWithQueue:(nonnull SKPaymentQueue *)queue _paymentQueueRestoreCompletedTransactionsFinished = restoreCompletedTransactionsFinished; _shouldAddStorePayment = shouldAddStorePayment; _updatedDownloads = updatedDownloads; + + if (@available(iOS 13.0, macOS 10.15, *)) { + queue.delegate = self.delegate; + } } return self; } @@ -78,6 +83,10 @@ - (void)presentCodeRedemptionSheet { } } +- (void)showPriceConsentIfNeeded { + [self.queue showPriceConsentIfNeeded]; +} + #pragma mark - observing // Sent when the transaction array has changed (additions or state changes). Client should check diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m index 8a998d9f4300..c0db38e5cfe0 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m @@ -5,6 +5,7 @@ #import "InAppPurchasePlugin.h" #import #import "FIAObjectTranslator.h" +#import "FIAPPaymentQueueDelegate.h" #import "FIAPReceiptManager.h" #import "FIAPRequestHandler.h" #import "FIAPaymentQueueHandler.h" @@ -19,13 +20,19 @@ @interface InAppPurchasePlugin () // for purchase. @property(strong, nonatomic, readonly) NSMutableDictionary *productsCache; -// Call back channel to dart used for when a listener function is triggered. -@property(strong, nonatomic, readonly) FlutterMethodChannel *callbackChannel; +// 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 *registry; @property(strong, nonatomic, readonly) NSObject *messenger; @property(strong, nonatomic, readonly) NSObject *registrar; @property(strong, nonatomic, readonly) FIAPReceiptManager *receiptManager; +@property(strong, nonatomic, readonly) + FIAPPaymentQueueDelegate *paymentQueueDelegate API_AVAILABLE(ios(13)); @end @@ -73,7 +80,8 @@ - (instancetype)initWithRegistrar:(NSObject *)registrar updatedDownloads:^void(NSArray *_Nonnull downloads) { [weakSelf updatedDownloads:downloads]; }]; - _callbackChannel = + + _transactionObserverCallbackChannel = [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/in_app_purchase" binaryMessenger:[registrar messenger]]; return self; @@ -100,9 +108,15 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else if ([@"-[InAppPurchasePlugin refreshReceipt:result:]" isEqualToString:call.method]) { [self refreshReceipt:call result:result]; } else if ([@"-[SKPaymentQueue startObservingTransactionQueue]" isEqualToString:call.method]) { - [_paymentQueueHandler startObservingPaymentQueue]; + [self startObservingPaymentQueue:result]; } else if ([@"-[SKPaymentQueue stopObservingTransactionQueue]" isEqualToString:call.method]) { - [_paymentQueueHandler stopObservingPaymentQueue]; + [self stopObservingPaymentQueue:result]; + } else if ([@"-[SKPaymentQueue registerDelegate]" isEqualToString:call.method]) { + [self registerPaymentQueueDelegate:result]; + } else if ([@"-[SKPaymentQueue removeDelegate]" isEqualToString:call.method]) { + [self removePaymentQueueDelegate:result]; + } else if ([@"-[SKPaymentQueue showPriceConsentIfNeeded]" isEqualToString:call.method]) { + [self showPriceConsentIfNeeded:result]; } else { result(FlutterMethodNotImplemented); } @@ -301,14 +315,53 @@ - (void)refreshReceipt:(FlutterMethodCall *)call result:(FlutterResult)result { }]; } -#pragma mark - delegates: +- (void)startObservingPaymentQueue:(FlutterResult)result { + [_paymentQueueHandler startObservingPaymentQueue]; + result(nil); +} + +- (void)stopObservingPaymentQueue:(FlutterResult)result { + [_paymentQueueHandler stopObservingPaymentQueue]; + result(nil); +} + +- (void)registerPaymentQueueDelegate:(FlutterResult)result { + if (@available(iOS 13.0, *)) { + _paymentQueueDelegateCallbackChannel = [FlutterMethodChannel + methodChannelWithName:@"plugins.flutter.io/in_app_purchase_payment_queue_delegate" + binaryMessenger:_messenger]; + + _paymentQueueDelegate = [[FIAPPaymentQueueDelegate alloc] + initWithMethodChannel:_paymentQueueDelegateCallbackChannel]; + _paymentQueueHandler.delegate = _paymentQueueDelegate; + } + result(nil); +} + +- (void)removePaymentQueueDelegate:(FlutterResult)result { + if (@available(iOS 13.0, *)) { + _paymentQueueHandler.delegate = nil; + } + _paymentQueueDelegate = nil; + _paymentQueueDelegateCallbackChannel = nil; + result(nil); +} + +- (void)showPriceConsentIfNeeded:(FlutterResult)result { + if (@available(iOS 13.4, *)) { + [_paymentQueueHandler showPriceConsentIfNeeded]; + } + result(nil); +} + +#pragma mark - transaction observer: - (void)handleTransactionsUpdated:(NSArray *)transactions { NSMutableArray *maps = [NSMutableArray new]; for (SKPaymentTransaction *transaction in transactions) { [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:transaction]]; } - [self.callbackChannel invokeMethod:@"updatedTransactions" arguments:maps]; + [self.transactionObserverCallbackChannel invokeMethod:@"updatedTransactions" arguments:maps]; } - (void)handleTransactionsRemoved:(NSArray *)transactions { @@ -316,17 +369,19 @@ - (void)handleTransactionsRemoved:(NSArray *)transaction for (SKPaymentTransaction *transaction in transactions) { [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:transaction]]; } - [self.callbackChannel invokeMethod:@"removedTransactions" arguments:maps]; + [self.transactionObserverCallbackChannel invokeMethod:@"removedTransactions" arguments:maps]; } - (void)handleTransactionRestoreFailed:(NSError *)error { - [self.callbackChannel invokeMethod:@"restoreCompletedTransactionsFailed" - arguments:[FIAObjectTranslator getMapFromNSError:error]]; + [self.transactionObserverCallbackChannel + invokeMethod:@"restoreCompletedTransactionsFailed" + arguments:[FIAObjectTranslator getMapFromNSError:error]]; } - (void)restoreCompletedTransactionsFinished { - [self.callbackChannel invokeMethod:@"paymentQueueRestoreCompletedTransactionsFinished" - arguments:nil]; + [self.transactionObserverCallbackChannel + invokeMethod:@"paymentQueueRestoreCompletedTransactionsFinished" + arguments:nil]; } - (void)updatedDownloads:(NSArray *)downloads { @@ -338,11 +393,12 @@ - (BOOL)shouldAddStorePayment:(SKPayment *)payment product:(SKProduct *)product // have a interception method that deciding if the payment should be processed (implemented by the // programmer). [self.productsCache setObject:product forKey:product.productIdentifier]; - [self.callbackChannel invokeMethod:@"shouldAddStorePayment" - arguments:@{ - @"payment" : [FIAObjectTranslator getMapFromSKPayment:payment], - @"product" : [FIAObjectTranslator getMapFromSKProduct:product] - }]; + [self.transactionObserverCallbackChannel + invokeMethod:@"shouldAddStorePayment" + arguments:@{ + @"payment" : [FIAObjectTranslator getMapFromSKPayment:payment], + @"product" : [FIAObjectTranslator getMapFromSKProduct:product] + }]; return NO; } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart index f8ab4d48be7e..d045dab448e8 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart @@ -7,3 +7,8 @@ import 'package:flutter/services.dart'; /// Method channel for the plugin's platform<-->Dart calls. const MethodChannel channel = MethodChannel('plugins.flutter.io/in_app_purchase'); + +/// Method channel used to deliver the payment queue delegate system calls to +/// Dart. +const MethodChannel paymentQueueDelegateChannel = + MethodChannel('plugins.flutter.io/in_app_purchase_payment_queue_delegate'); diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart index 0c7b2de860b6..bcc4ddf48200 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart @@ -30,4 +30,28 @@ class InAppPurchaseIosPlatformAddition extends InAppPurchasePlatformAddition { serverVerificationData: receipt, source: kIAPSource); } + + /// Sets an implementation of the [SKPaymentQueueDelegateWrapper]. + /// + /// The [SKPaymentQueueDelegateWrapper] can be used to inform iOS how to + /// finish transactions when the storefront changes or if the price consent + /// sheet should be displayed when the price of a subscription has changed. If + /// no delegate is registered iOS will fallback to it's default configuration. + /// See the documentation on StoreKite's [`-[SKPaymentQueue delegate:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc). + /// + /// When set to `null` the payment queue delegate will be removed and the + /// default behaviour will apply (see [documentation](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc)). + Future setDelegate(SKPaymentQueueDelegateWrapper? delegate) => + SKPaymentQueueWrapper().setDelegate(delegate); + + /// Shows the price consent sheet if the user has not yet responded to a + /// subscription price change. + /// + /// Use this function when you have registered a [SKPaymentQueueDelegateWrapper] + /// (using the [setDelegate] method) and returned `false` when the + /// `SKPaymentQueueDelegateWrapper.shouldShowPriceConsent()` method was called. + /// + /// See documentation of StoreKit's [`-[SKPaymentQueue showPriceConsentIfNeeded]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded?language=objc). + Future showPriceConsentIfNeeded() => + SKPaymentQueueWrapper().showPriceConsentIfNeeded(); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart new file mode 100644 index 000000000000..2759a296389b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart @@ -0,0 +1,39 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +/// A wrapper around +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +/// +/// The [SKPaymentQueueDelegateWrapper] is only available on iOS 13 and higher. +/// Using the delegate on older iOS version will be ignored. +abstract class SKPaymentQueueDelegateWrapper { + /// Called by the system to check whether the transaction should continue if + /// the device's App Store storefront has changed during a transaction. + /// + /// - Return `true` if the transaction should continue within the updated + /// storefront (default behaviour). + /// - Return `false` if the transaction should be cancelled. In this case the + /// transaction will fail with the error [SKErrorStoreProductNotAvailable](https://developer.apple.com/documentation/storekit/skerrorcode/skerrorstoreproductnotavailable?language=objc). + /// + /// See the documentation in StoreKit's [`[-SKPaymentQueueDelegate shouldContinueTransaction]`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3242935-paymentqueue?language=objc). + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, + SKStorefrontWrapper storefront, + ) => + true; + + /// Called by the system to check whether to immediately show the price + /// consent form. + /// + /// The default return value is `true`. This will inform the system to display + /// the price consent sheet when the subscription price has been changed in + /// App Store Connect and the subscriber has not yet taken action. See the + /// documentation in StoreKit's [`[-SKPaymentQueueDelegate shouldShowPriceConsent:]`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc). + bool shouldShowPriceConsent() => true; +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart index fe5f14ba44a5..c39ad9efddd7 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart @@ -6,12 +6,15 @@ import 'dart:async'; import 'dart:ui' show hashValues; import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import '../channel.dart'; import '../in_app_purchase_ios_platform.dart'; +import 'sk_payment_queue_delegate_wrapper.dart'; import 'sk_payment_transaction_wrappers.dart'; import 'sk_product_wrapper.dart'; @@ -40,6 +43,7 @@ class SKPaymentQueueWrapper { static final SKPaymentQueueWrapper _singleton = SKPaymentQueueWrapper._(); + SKPaymentQueueDelegateWrapper? _paymentQueueDelegate; SKTransactionObserverWrapper? _observer; /// Calls [`-[SKPaymentQueue transactions]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506026-transactions?language=objc) @@ -70,18 +74,39 @@ class SKPaymentQueueWrapper { /// /// Call this method when the first listener is subscribed to the /// [InAppPurchaseIosPlatform.purchaseStream]. - Future startObservingTransactionQueue() async => - await channel.invokeListMethod( - '-[SKPaymentQueue startObservingTransactionQueue]'); + Future startObservingTransactionQueue() => channel + .invokeMethod('-[SKPaymentQueue startObservingTransactionQueue]'); /// Instructs the iOS implementation to remove the transaction observer and /// stop listening to it. /// /// Call this when there are no longer any listeners subscribed to the /// [InAppPurchaseIosPlatform.purchaseStream]. - Future stopObservingTransactionQueue() async => - await channel.invokeListMethod( - '-[SKPaymentQueue stopObservingTransactionQueue]'); + Future stopObservingTransactionQueue() => channel + .invokeMethod('-[SKPaymentQueue stopObservingTransactionQueue]'); + + /// Sets an implementation of the [SKPaymentQueueDelegateWrapper]. + /// + /// The [SKPaymentQueueDelegateWrapper] can be used to inform iOS how to + /// finish transactions when the storefront changes or if the price consent + /// sheet should be displayed when the price of a subscription has changed. If + /// no delegate is registered iOS will fallback to it's default configuration. + /// See the documentation on StoreKite's [`-[SKPaymentQueue delegate:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc). + /// + /// When set to `null` the payment queue delegate will be removed and the + /// default behaviour will apply (see [documentation](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc)). + Future setDelegate(SKPaymentQueueDelegateWrapper? delegate) async { + if (delegate == null) { + await channel.invokeMethod('-[SKPaymentQueue removeDelegate]'); + paymentQueueDelegateChannel.setMethodCallHandler(null); + } else { + await channel.invokeMethod('-[SKPaymentQueue registerDelegate]'); + paymentQueueDelegateChannel + .setMethodCallHandler(handlePaymentQueueDelegateCallbacks); + } + + _paymentQueueDelegate = delegate; + } /// Posts a payment to the queue. /// @@ -170,8 +195,21 @@ class SKPaymentQueueWrapper { '-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]'); } + /// Shows the price consent sheet if the user has not yet responded to a + /// subscription price change. + /// + /// Use this function when you have registered a [SKPaymentQueueDelegateWrapper] + /// (using the [setDelegate] method) and returned `false` when the + /// `SKPaymentQueueDelegateWrapper.shouldShowPriceConsent()` method was called. + /// + /// See documentation of StoreKit's [`-[SKPaymentQueue showPriceConsentIfNeeded]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded?language=objc). + Future showPriceConsentIfNeeded() async { + await channel + .invokeMethod('-[SKPaymentQueue showPriceConsentIfNeeded]'); + } + // Triage a method channel call from the platform and triggers the correct observer method. - Future _handleObserverCallbacks(MethodCall call) async { + Future _handleObserverCallbacks(MethodCall call) async { assert(_observer != null, '[in_app_purchase]: (Fatal)The observer has not been set but we received a purchase transaction notification. Please ensure the observer has been set using `setTransactionObserver`. Make sure the observer is added right at the App Launch.'); final SKTransactionObserverWrapper observer = _observer!; @@ -235,6 +273,35 @@ class SKPaymentQueueWrapper { Map.castFrom(map)); }).toList(); } + + /// Triage a method channel call from the platform and triggers the correct + /// payment queue delegate method. + /// + /// This method is public for testing purposes only and should not be used + /// outside this class. + @visibleForTesting + Future handlePaymentQueueDelegateCallbacks(MethodCall call) async { + assert(_paymentQueueDelegate != null, + '[in_app_purchase]: (Fatal)The payment queue delegate has not been set but we received a payment queue notification. Please ensure the payment queue has been set using `setDelegate`.'); + + final SKPaymentQueueDelegateWrapper delegate = _paymentQueueDelegate!; + switch (call.method) { + case 'shouldContinueTransaction': + final SKPaymentTransactionWrapper transaction = + SKPaymentTransactionWrapper.fromJson(call.arguments['transaction']); + final SKStorefrontWrapper storefront = + SKStorefrontWrapper.fromJson(call.arguments['storefront']); + return delegate.shouldContinueTransaction(transaction, storefront); + case 'shouldShowPriceConsent': + return delegate.shouldShowPriceConsent(); + default: + break; + } + throw PlatformException( + code: 'no_such_callback', + message: + 'Did not recognize the payment queue delegate callback ${call.method}.'); + } } /// Dart wrapper around StoreKit's diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart new file mode 100644 index 000000000000..934fdea355e3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart @@ -0,0 +1,65 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show hashValues; + +import 'package:json_annotation/json_annotation.dart'; + +part 'sk_storefront_wrapper.g.dart'; + +/// Contains the location and unique identifier of an Apple App Store storefront. +/// +/// Dart wrapper around StoreKit's +/// [SKStorefront](https://developer.apple.com/documentation/storekit/skstorefront?language=objc). +@JsonSerializable() +class SKStorefrontWrapper { + /// Creates a new [SKStorefrontWrapper] with the provided information. + SKStorefrontWrapper({ + required this.countryCode, + required this.identifier, + }); + + /// Constructs an instance of the [SKStorefrontWrapper] from a key value map + /// of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. The `map` parameter must not be + /// null. + factory SKStorefrontWrapper.fromJson(Map map) { + return _$SKStorefrontWrapperFromJson(map); + } + + /// The three-letter code representing the country or region associated with + /// the App Store storefront. + final String countryCode; + + /// A value defined by Apple that uniquely identifies an App Store storefront. + final String identifier; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKStorefrontWrapper typedOther = other as SKStorefrontWrapper; + return typedOther.countryCode == countryCode && + typedOther.identifier == identifier; + } + + @override + int get hashCode => hashValues( + this.countryCode, + this.identifier, + ); + + @override + String toString() => _$SKStorefrontWrapperToJson(this).toString(); + + /// Converts the instance to a key value map which can be used to serialize + /// to JSON format. + Map toMap() => _$SKStorefrontWrapperToJson(this); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart new file mode 100644 index 000000000000..f75cfc5711e8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sk_storefront_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SKStorefrontWrapper _$SKStorefrontWrapperFromJson(Map json) { + return SKStorefrontWrapper( + countryCode: json['countryCode'] as String, + identifier: json['identifier'] as String, + ); +} + +Map _$SKStorefrontWrapperToJson( + SKStorefrontWrapper instance) => + { + 'countryCode': instance.countryCode, + 'identifier': instance.identifier, + }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart index b687d238083c..09eb1acb8420 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart @@ -2,8 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart'; export 'src/store_kit_wrappers/sk_payment_queue_wrapper.dart'; export 'src/store_kit_wrappers/sk_payment_transaction_wrappers.dart'; export 'src/store_kit_wrappers/sk_product_wrapper.dart'; export 'src/store_kit_wrappers/sk_receipt_manager.dart'; export 'src/store_kit_wrappers/sk_request_maker.dart'; +export 'src/store_kit_wrappers/sk_storefront_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 5b9e3892d40d..00929d9c024b 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.0+2 +version: 0.1.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index edb50aeb62a0..6a01fe4caecb 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -145,6 +145,20 @@ void main() { await SKPaymentQueueWrapper().stopObservingTransactionQueue(); expect(fakeIOSPlatform.queueIsActive, false); }); + + test('setDelegate should call methodChannel', () async { + expect(fakeIOSPlatform.isPaymentQueueDelegateRegistered, false); + await SKPaymentQueueWrapper().setDelegate(TestPaymentQueueDelegate()); + expect(fakeIOSPlatform.isPaymentQueueDelegateRegistered, true); + await SKPaymentQueueWrapper().setDelegate(null); + expect(fakeIOSPlatform.isPaymentQueueDelegateRegistered, false); + }); + + test('showPriceConsentIfNeeded should call methodChannel', () async { + expect(fakeIOSPlatform.showPriceConsentIfNeeded, false); + await SKPaymentQueueWrapper().showPriceConsentIfNeeded(); + expect(fakeIOSPlatform.showPriceConsentIfNeeded, true); + }); }); group('Code Redemption Sheet', () { @@ -178,6 +192,12 @@ class FakeIOSPlatform { // present Code Redemption bool presentCodeRedemption = false; + // show price consent sheet + bool showPriceConsentIfNeeded = false; + + // indicate if the payment queue delegate is registered + bool isPaymentQueueDelegateRegistered = false; + // Listen to purchase updates bool? queueIsActive; @@ -230,11 +250,22 @@ class FakeIOSPlatform { case '-[SKPaymentQueue stopObservingTransactionQueue]': queueIsActive = false; return Future.sync(() {}); + case '-[SKPaymentQueue registerDelegate]': + isPaymentQueueDelegateRegistered = true; + return Future.sync(() {}); + case '-[SKPaymentQueue removeDelegate]': + isPaymentQueueDelegateRegistered = false; + return Future.sync(() {}); + case '-[SKPaymentQueue showPriceConsentIfNeeded]': + showPriceConsentIfNeeded = true; + return Future.sync(() {}); } return Future.error('method not mocked'); } } +class TestPaymentQueueDelegate extends SKPaymentQueueDelegateWrapper {} + class TestPaymentTransactionObserver extends SKTransactionObserverWrapper { void updatedTransactions( {required List transactions}) {} diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart new file mode 100644 index 000000000000..b61411dfffa4 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart @@ -0,0 +1,109 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_ios/src/channel.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakeIOSPlatform fakeIOSPlatform = FakeIOSPlatform(); + + setUpAll(() { + SystemChannels.platform + .setMockMethodCallHandler(fakeIOSPlatform.onMethodCall); + }); + + test( + 'handlePaymentQueueDelegateCallbacks should call SKPaymentQueueDelegateWrapper.shouldContinueTransaction', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentQueueDelegate testDelegate = TestPaymentQueueDelegate(); + await queue.setDelegate(testDelegate); + + final Map arguments = { + 'storefront': { + 'countryCode': 'USA', + 'identifier': 'unique_identifier', + }, + 'transaction': { + 'payment': { + 'productIdentifier': 'product_identifier', + } + }, + }; + + final result = await queue.handlePaymentQueueDelegateCallbacks( + MethodCall('shouldContinueTransaction', arguments), + ); + + expect(result, false); + expect( + testDelegate.log, + { + equals('shouldContinueTransaction'), + }, + ); + }); + + test( + 'handlePaymentQueueDelegateCallbacks should call SKPaymentQueueDelegateWrapper.shouldShowPriceConsent', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentQueueDelegate testDelegate = TestPaymentQueueDelegate(); + await queue.setDelegate(testDelegate); + + final result = await queue.handlePaymentQueueDelegateCallbacks( + MethodCall('shouldShowPriceConsent'), + ); + + expect(result, false); + expect( + testDelegate.log, + { + equals('shouldShowPriceConsent'), + }, + ); + }); +} + +class TestPaymentQueueDelegate extends SKPaymentQueueDelegateWrapper { + final List log = []; + + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + log.add('shouldContinueTransaction'); + return false; + } + + @override + bool shouldShowPriceConsent() { + log.add('shouldShowPriceConsent'); + return false; + } +} + +class FakeIOSPlatform { + FakeIOSPlatform() { + channel.setMockMethodCallHandler(onMethodCall); + } + + // indicate if the payment queue delegate is registered + bool isPaymentQueueDelegateRegistered = false; + + Future onMethodCall(MethodCall call) { + switch (call.method) { + case '-[SKPaymentQueue registerDelegate]': + isPaymentQueueDelegateRegistered = true; + return Future.sync(() {}); + case '-[SKPaymentQueue removeDelegate]': + isPaymentQueueDelegateRegistered = false; + return Future.sync(() {}); + } + return Future.error('method not mocked'); + } +}