From 020800bd3c4c6d960d7066d4df466daa28d59164 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 28 Oct 2021 21:38:16 +0200 Subject: [PATCH 1/8] Support promotional offers --- .../example/ios/RunnerTests/TranslatorTests.m | 5 + .../ios/Classes/FIAObjectTranslator.h | 3 + .../ios/Classes/FIAObjectTranslator.m | 20 +++ .../sk_payment_queue_wrapper.dart | 117 +++++++++++++++++- .../sk_payment_queue_wrapper.g.dart | 19 +++ .../sk_product_wrapper.dart | 19 ++- .../sk_product_wrapper.g.dart | 5 + .../store_kit_wrappers/sk_product_test.dart | 1 + .../sk_test_stub_objects.dart | 2 + 9 files changed, 182 insertions(+), 9 deletions(-) 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 89a7b2c84380..bf6ea272cd9e 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 @@ -14,6 +14,7 @@ @interface TranslatorTest : XCTestCase @property(strong, nonatomic) NSMutableDictionary *productMap; @property(strong, nonatomic) NSDictionary *productResponseMap; @property(strong, nonatomic) NSDictionary *paymentMap; +@property(strong, nonatomic) NSDictionary *paymentDiscountMap; @property(strong, nonatomic) NSDictionary *transactionMap; @property(strong, nonatomic) NSDictionary *errorMap; @property(strong, nonatomic) NSDictionary *localeMap; @@ -50,6 +51,10 @@ - (void)setUp { self.productMap[@"subscriptionGroupIdentifier"] = @"com.group"; } + if (@available(iOS 11.2, *)) { + self.productMap[@"discounts"] = @[ self.discountMap ]; + } + self.productResponseMap = @{@"products" : @[ self.productMap ], @"invalidProductIdentifiers" : @[]}; self.paymentMap = @{ 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 95a5edc245dc..eca4aeee13c1 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 @@ -47,6 +47,9 @@ NS_ASSUME_NONNULL_BEGIN andSKPaymentTransaction:(SKPaymentTransaction *)transaction API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); +// Creates an instance of the SKPaymentDiscount class based on the supplied disctionary. ++ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map API_AVAILABLE(ios(12.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 0125604b3b3c..c798add499e2 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 @@ -193,4 +193,24 @@ + (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront return map; } ++ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map { + if (!map) { + return nil; + } + + NSString *identifier = map[@"identifier"]; + NSString *keyIdentifier = map[@"keyIdentifier"]; + NSUUID *nonce = [[NSUUID alloc] initWithUUIDString:map[@"nonce"]]; + NSString *signature = map[@"signature"]; + NSNumber *timestamp = map[@"timestamp"]; + + SKPaymentDiscount *discount = [[SKPaymentDiscount alloc] initWithIdentifier:identifier + keyIdentifier:keyIdentifier + nonce:nonce + signature:signature + timestamp:timestamp]; + + return discount; +} + @end 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 3decba2a9818..918d86ee4b3a 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 @@ -378,12 +378,14 @@ class SKError { @JsonSerializable(createToJson: true) class SKPaymentWrapper { /// Creates a new [SKPaymentWrapper] with the provided information. - const SKPaymentWrapper( - {required this.productIdentifier, - this.applicationUsername, - this.requestData, - this.quantity = 1, - this.simulatesAskToBuyInSandbox = false}); + const SKPaymentWrapper({ + required this.productIdentifier, + this.applicationUsername, + this.requestData, + this.quantity = 1, + this.simulatesAskToBuyInSandbox = false, + this.paymentDiscount, + }); /// Constructs an instance of this from a key value map of data. /// @@ -450,6 +452,13 @@ class SKPaymentWrapper { /// testing. final bool simulatesAskToBuyInSandbox; + /// The details of optional discount that should be applied to the payment. + /// + /// See [Setting Up Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/setting_up_promotional_offers?language=objc) + /// for more information on generating keys and creating offers for + /// auto-renewable subscriptions. + final SKPaymentDiscountWrapper? paymentDiscount; + @override bool operator ==(Object other) { if (identical(other, this)) { @@ -473,3 +482,99 @@ class SKPaymentWrapper { @override String toString() => _$SKPaymentWrapperToJson(this).toString(); } + +/// Dart wrapper around StoreKit's +/// [SKPaymentDiscount](https://developer.apple.com/documentation/storekit/skpaymentdiscount?language=objc). +/// +/// Used to indicate a discount is applicable to a payment. The +/// [SKPaymentDiscountWrapper] instance should be assigned to the +/// [SKPaymentWrapper] object to which the discount should be applied. +/// Discount offers are set up in App Store Connect. +@immutable +@JsonSerializable(createToJson: true) +class SKPaymentDiscountWrapper { + /// Creates a new [SKPaymentDiscountWrapper] with the provided information. + const SKPaymentDiscountWrapper({ + required this.identifier, + required this.keyIdentifier, + required this.nonce, + required this.signature, + required this.timestamp, + }); + + /// Constructs an instance of this from a key value map of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. The `map` parameter must not be + /// null. + factory SKPaymentDiscountWrapper.fromJson(Map map) { + assert(map != null); + return _$SKPaymentDiscountWrapperFromJson(map); + } + + /// Creates a Map object describes the payment object. + Map toMap() { + return { + 'identifier': identifier, + 'keyIdentifier': keyIdentifier, + 'nonce': nonce, + 'signature': signature, + 'timestamp': timestamp, + }; + } + + /// The identifier of the discount offer. + /// + /// The identifier must match one of the offers set up in App Store Connect. + final String identifier; + + /// A string identifying the key that is used to generate the signature. + /// + /// Keys are generated and downloaded from App Store Connect. See the "KEY ID" + /// column in the App Store Promotions section in App Store Connect to use as + /// the keyIdentifier. + final String keyIdentifier; + + /// A universal unique identifier created together with the signature. + /// + /// The unique nonce should be generated on your server when it creates the + /// `signature` for the payment discount. The nonce can be used once, a new + /// nonce should be created for each payment request. + /// The string representation of the nonce must be lowercase. + final String nonce; + + /// A cryptographically signed string representing the to properties of the + /// promotional offer. + /// + /// The signature is string signed with a private key and contains all the + /// properties of the promotional offer. To keep you private key secure the + /// signature should be created on a server. See [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc) + /// for more information. + final String signature; + + /// The date and time the signature was created. + /// + /// The timestamp should be formatted in Unix epoch time. + final int timestamp; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKPaymentDiscountWrapper typedOther = + other as SKPaymentDiscountWrapper; + return typedOther.identifier == identifier && + typedOther.keyIdentifier == keyIdentifier && + typedOther.nonce == nonce && + typedOther.signature == signature && + typedOther.timestamp == timestamp; + } + + @override + int get hashCode => + hashValues(identifier, keyIdentifier, nonce, signature, timestamp); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart index 8c0e64b2b9f6..f594ad450440 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart @@ -32,3 +32,22 @@ Map _$SKPaymentWrapperToJson(SKPaymentWrapper instance) => 'quantity': instance.quantity, 'simulatesAskToBuyInSandbox': instance.simulatesAskToBuyInSandbox, }; + +SKPaymentDiscountWrapper _$SKPaymentDiscountWrapperFromJson(Map json) => + SKPaymentDiscountWrapper( + identifier: json['identifier'] as String, + keyIdentifier: json['keyIdentifier'] as String, + nonce: json['nonce'] as String, + signature: json['signature'] as String, + timestamp: json['timestamp'] as int, + ); + +Map _$SKPaymentDiscountWrapperToJson( + SKPaymentDiscountWrapper instance) => + { + 'identifier': instance.identifier, + 'keyIdentifier': instance.keyIdentifier, + 'nonce': instance.nonce, + 'signature': instance.signature, + 'timestamp': instance.timestamp, + }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart index 1b681f24f8db..b43a0ef18519 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart @@ -241,7 +241,8 @@ class SKProductWrapper { required this.price, this.subscriptionPeriod, this.introductoryPrice, - }); + List? discounts, + }) : this.discounts = discounts ?? []; /// Constructing an instance from a map from the Objective-C layer. /// @@ -295,6 +296,16 @@ class SKProductWrapper { /// and their units and duration do not have to be matched. final SKProductDiscountWrapper? introductoryPrice; + /// An array of subscription offers available for the auto-renewable subscription (available on iOS 12.2 and higher). + /// + /// This property lists all promotional offers set up in App Store Connect. If + /// no promotional offers have been set up this field returns an empty list. + /// Each [subscriptionPeriod] of individual discounts are independent of the + /// product's [subscriptionPeriod] and their units and duration do not have to + /// be matched. + @JsonKey(defaultValue: []) + final List discounts; + @override bool operator ==(Object other) { if (identical(other, this)) { @@ -311,7 +322,8 @@ class SKProductWrapper { typedOther.subscriptionGroupIdentifier == subscriptionGroupIdentifier && typedOther.price == price && typedOther.subscriptionPeriod == subscriptionPeriod && - typedOther.introductoryPrice == introductoryPrice; + typedOther.introductoryPrice == introductoryPrice && + DeepCollectionEquality().equals(typedOther.discounts, discounts); } @override @@ -323,7 +335,8 @@ class SKProductWrapper { this.subscriptionGroupIdentifier, this.price, this.subscriptionPeriod, - this.introductoryPrice); + this.introductoryPrice, + this.discounts); } /// Object that indicates the locale of the price diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart index c9079ff64a1e..6eea3ff34da0 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart @@ -65,6 +65,11 @@ SKProductWrapper _$SKProductWrapperFromJson(Map json) => SKProductWrapper( ? null : SKProductDiscountWrapper.fromJson( Map.from(json['introductoryPrice'] as Map)), + discounts: (json['discounts'] as List?) + ?.map((e) => SKProductDiscountWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], ); SKPriceLocaleWrapper _$SKPriceLocaleWrapperFromJson(Map json) => diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart index 6a33b75d9808..4d010aad132c 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart @@ -84,6 +84,7 @@ void main() { expect(wrapper.subscriptionGroupIdentifier, null); expect(wrapper.price, ''); expect(wrapper.subscriptionPeriod, null); + expect(wrapper.discounts, []); }); test('toProductDetails() should return correct Product object', () { diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart index 595a074f1cfe..a2cf2a0cccfc 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart @@ -68,6 +68,7 @@ final SKProductWrapper dummyProductWrapper = SKProductWrapper( price: '1.0', subscriptionPeriod: dummySubscription, introductoryPrice: dummyDiscount, + discounts: [dummyDiscount], ); final SkProductResponseWrapper dummyProductResponseWrapper = @@ -118,6 +119,7 @@ Map buildProductMap(SKProductWrapper product) { 'subscriptionPeriod': buildSubscriptionPeriodMap(product.subscriptionPeriod), 'introductoryPrice': buildDiscountMap(product.introductoryPrice!), + 'discounts': [buildDiscountMap(product.introductoryPrice!)], }; } From d89dee596c1ec1f9d2c155f57328226d0605e4c0 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 28 Oct 2021 21:39:37 +0200 Subject: [PATCH 2/8] Support promotional offers --- .../example/ios/RunnerTests/Stubs.m | 8 ++++++++ .../example/ios/RunnerTests/TranslatorTests.m | 4 ++++ .../ios/Classes/FIAObjectTranslator.h | 4 ++++ .../ios/Classes/FIAObjectTranslator.m | 15 +++++++++++++++ 4 files changed, 31 insertions(+) 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 364505d6754a..4761c12feb7b 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 @@ -61,6 +61,14 @@ - (instancetype)initWithMap:(NSDictionary *)map { [self setValue:map[@"subscriptionGroupIdentifier"] ?: [NSNull null] forKey:@"subscriptionGroupIdentifier"]; } + if (@available(iOS 12.2, *)) { + NSMutableArray *discounts = [NSMutableArray new]; + for (NSDictionary *discountMap in map[@"discounts"]) { + [discounts addObject:[[SKProductDiscountStub alloc] initWithMap:discountMap]]; + } + + [self setValue:discounts forKey:@"discounts"]; + } } return self; } 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 89a7b2c84380..455dd3e8fe95 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 @@ -45,6 +45,10 @@ - (void)setUp { self.productMap[@"subscriptionPeriod"] = self.periodMap; self.productMap[@"introductoryPrice"] = self.discountMap; } + if (@available(iOS 12.2, *)) { + NSArray *discounts = [[NSArray alloc] initWithObjects:self.discountMap, nil]; + self.productMap[@"discounts"] = discounts; + } if (@available(iOS 12.0, *)) { self.productMap[@"subscriptionGroupIdentifier"] = @"com.group"; 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 95a5edc245dc..cf48a919e4c8 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 @@ -20,6 +20,10 @@ NS_ASSUME_NONNULL_BEGIN + (NSDictionary *)getMapFromSKProductDiscount:(SKProductDiscount *)discount API_AVAILABLE(ios(11.2)); +// Converts an array of SKProductDiscount instances into an array of dictionaries. ++ (nonnull NSArray *)getMapArrayFromSKProductDiscounts:(nonnull NSArray *)productDiscounts + API_AVAILABLE(ios(12.2)); + // Converts an instance of SKProductsResponse into a dictionary. + (NSDictionary *)getMapFromSKProductsResponse:(SKProductsResponse *)productResponse; 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 0125604b3b3c..e3a78dedd630 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 @@ -24,6 +24,7 @@ + (NSDictionary *)getMapFromSKProduct:(SKProduct *)product { // https://github.com/flutter/flutter/issues/26610 [map setObject:[FIAObjectTranslator getMapFromNSLocale:product.priceLocale] ?: [NSNull null] forKey:@"priceLocale"]; + if (@available(iOS 11.2, *)) { [map setObject:[FIAObjectTranslator getMapFromSKProductSubscriptionPeriod:product.subscriptionPeriod] @@ -35,6 +36,10 @@ + (NSDictionary *)getMapFromSKProduct:(SKProduct *)product { ?: [NSNull null] forKey:@"introductoryPrice"]; } + if (@available(iOS 12.2, *)) { + [map setObject:[FIAObjectTranslator getMapArrayFromSKProductDiscounts:product.discounts] + forKey:@"discounts"]; + } if (@available(iOS 12.0, *)) { [map setObject:product.subscriptionGroupIdentifier ?: [NSNull null] forKey:@"subscriptionGroupIdentifier"]; @@ -49,6 +54,16 @@ + (NSDictionary *)getMapFromSKProductSubscriptionPeriod:(SKProductSubscriptionPe return @{@"numberOfUnits" : @(period.numberOfUnits), @"unit" : @(period.unit)}; } ++ (nonnull NSArray *)getMapArrayFromSKProductDiscounts:(nonnull NSArray *)productDiscounts { + NSMutableArray *discountsMapArray = [NSMutableArray new]; + + for (SKProductDiscount *productDiscount in productDiscounts) { + [discountsMapArray addObject:[FIAObjectTranslator getMapFromSKProductDiscount:productDiscount]]; + } + + return discountsMapArray; +} + + (NSDictionary *)getMapFromSKProductDiscount:(SKProductDiscount *)discount { if (!discount) { return nil; From 6abe36da303bad99bbc765ff8c3fedf3d3799fd5 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 2 Nov 2021 11:09:33 +0100 Subject: [PATCH 3/8] Add paymentDiscount to payment on native side --- .../RunnerTests/InAppPurchasePluginTests.m | 58 ++++++++++++++++++- .../ios/Classes/InAppPurchasePlugin.m | 6 ++ 2 files changed, 63 insertions(+), 1 deletion(-) 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 b51f622e939b..fa2d784a1556 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 @@ -111,7 +111,7 @@ - (void)testAddPaymentFailure { XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStateFailed); } -- (void)testAddPaymentSuccessWithMockQueue { +- (void)testAddPaymentSuccessWithoutPaymentDiscount { XCTestExpectation* expectation = [self expectationWithDescription:@"result should return success state"]; FlutterMethodCall* call = @@ -129,6 +129,62 @@ - (void)testAddPaymentSuccessWithMockQueue { 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]; + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStatePurchased); +} + +- (void)testAddPaymentSuccessWithPaymentDiscount { + XCTestExpectation* expectation = + [self expectationWithDescription:@"result should return success state"]; + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin addPayment:result:]" + arguments:@{ + @"productIdentifier" : @"123", + @"quantity" : @(1), + @"simulatesAskToBuyInSandbox" : @YES, + @"paymentDiscount" : @{ + @"identifier" : @"test_identifier", + @"keyIdentifier" : @"test_key_identifier", + @"nonce" : @"4a11a9cc-3bc3-11ec-8d3d-0242ac130003", + @"signature" : @"test_signature", + @"timestamp" : @(1635847102), + } + }]; + SKPaymentQueueStub* queue = [SKPaymentQueueStub new]; + queue.testState = SKPaymentTransactionStatePurchased; + __block SKPaymentTransaction* transactionForUpdateBlock; + self.plugin.paymentQueueHandler = [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:^(NSArray* _Nonnull transactions) { + SKPaymentTransaction* transaction = transactions[0]; + if (transaction.transactionState == SKPaymentTransactionStatePurchased) { + transactionForUpdateBlock = transaction; + 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]; } } 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 7e2d2ca80675..6d89c99cf4be 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 @@ -203,6 +203,12 @@ - (void)addPayment:(FlutterMethodCall *)call result:(FlutterResult)result { ? NO : [simulatesAskToBuyInSandbox boolValue]; + if (@available(iOS 12.2, *)) { + SKPaymentDiscount *paymentDiscount = [FIAObjectTranslator + getSKPaymentDiscountFromMap:[paymentMap objectForKey:@"paymentDiscount"]]; + payment.paymentDiscount = paymentDiscount; + } + if (![self.paymentQueueHandler addPayment:payment]) { result([FlutterError errorWithCode:@"storekit_duplicate_product_object" From ffc2ca58d2d14de97e67f35af9c390c6029a7817 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 2 Nov 2021 11:51:18 +0100 Subject: [PATCH 4/8] Added feedback on PR --- .../example/ios/RunnerTests/Stubs.m | 2 +- .../example/ios/RunnerTests/TranslatorTests.m | 2 +- .../ios/Classes/FIAObjectTranslator.h | 2 +- .../ios/Classes/FIAObjectTranslator.m | 3 +- .../sk_payment_queue_wrapper.dart | 35 +++++++++++-------- .../sk_product_wrapper.dart | 2 +- 6 files changed, 25 insertions(+), 21 deletions(-) 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 6de005c7a6ab..e4277d3edd59 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 @@ -62,7 +62,7 @@ - (instancetype)initWithMap:(NSDictionary *)map { forKey:@"subscriptionGroupIdentifier"]; } if (@available(iOS 12.2, *)) { - NSMutableArray *discounts = [NSMutableArray new]; + NSMutableArray *discounts = [[NSMutableArray alloc] init]; for (NSDictionary *discountMap in map[@"discounts"]) { [discounts addObject:[[SKProductDiscountStub alloc] initWithMap:discountMap]]; } 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 8ea838fcac45..6ed4a3ba3a9b 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 @@ -14,7 +14,7 @@ @interface TranslatorTest : XCTestCase @property(strong, nonatomic) NSMutableDictionary *productMap; @property(strong, nonatomic) NSDictionary *productResponseMap; @property(strong, nonatomic) NSDictionary *paymentMap; -@property(strong, nonatomic) NSDictionary *paymentDiscountMap; +@property(copy, nonatomic) NSDictionary *paymentDiscountMap; @property(strong, nonatomic) NSDictionary *transactionMap; @property(strong, nonatomic) NSDictionary *errorMap; @property(strong, nonatomic) NSDictionary *localeMap; 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 7181760e3a72..e843ccd1f703 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 @@ -51,7 +51,7 @@ NS_ASSUME_NONNULL_BEGIN andSKPaymentTransaction:(SKPaymentTransaction *)transaction API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); -// Creates an instance of the SKPaymentDiscount class based on the supplied disctionary. +// Creates an instance of the SKPaymentDiscount class based on the supplied dictionary. + (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map API_AVAILABLE(ios(12.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 6a5b00c53fcf..6bbebc5fef05 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 @@ -24,7 +24,6 @@ + (NSDictionary *)getMapFromSKProduct:(SKProduct *)product { // https://github.com/flutter/flutter/issues/26610 [map setObject:[FIAObjectTranslator getMapFromNSLocale:product.priceLocale] ?: [NSNull null] forKey:@"priceLocale"]; - if (@available(iOS 11.2, *)) { [map setObject:[FIAObjectTranslator getMapFromSKProductSubscriptionPeriod:product.subscriptionPeriod] @@ -56,7 +55,7 @@ + (NSDictionary *)getMapFromSKProductSubscriptionPeriod:(SKProductSubscriptionPe + (nonnull NSArray *)getMapArrayFromSKProductDiscounts: (nonnull NSArray *)productDiscounts { - NSMutableArray *discountsMapArray = [NSMutableArray new]; + NSMutableArray *discountsMapArray = [[NSMutableArray alloc] init]; for (SKProductDiscount *productDiscount in productDiscounts) { [discountsMapArray addObject:[FIAObjectTranslator getMapFromSKProductDiscount:productDiscount]]; 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 918d86ee4b3a..918eac05822a 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 @@ -452,11 +452,12 @@ class SKPaymentWrapper { /// testing. final bool simulatesAskToBuyInSandbox; - /// The details of optional discount that should be applied to the payment. + /// The details of a discount that should be applied to the payment. /// - /// See [Setting Up Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/setting_up_promotional_offers?language=objc) + /// See [Implementing Promotional Offers in Your App](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app?language=objc) /// for more information on generating keys and creating offers for - /// auto-renewable subscriptions. + /// auto-renewable subscriptions. If set to `null` no discount will be + /// applied to this payment. final SKPaymentDiscountWrapper? paymentDiscount; @override @@ -489,7 +490,8 @@ class SKPaymentWrapper { /// Used to indicate a discount is applicable to a payment. The /// [SKPaymentDiscountWrapper] instance should be assigned to the /// [SKPaymentWrapper] object to which the discount should be applied. -/// Discount offers are set up in App Store Connect. +/// Discount offers are set up in App Store Connect. See [Implementing Promotional Offers in Your App](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app?language=objc) +/// for more information. @immutable @JsonSerializable(createToJson: true) class SKPaymentDiscountWrapper { @@ -505,8 +507,7 @@ class SKPaymentDiscountWrapper { /// Constructs an instance of this from a key value map of data. /// /// The map needs to have named string keys with values matching the names and - /// types of all of the members on this class. The `map` parameter must not be - /// null. + /// types of all of the members on this class. factory SKPaymentDiscountWrapper.fromJson(Map map) { assert(map != null); return _$SKPaymentDiscountWrapperFromJson(map); @@ -530,17 +531,19 @@ class SKPaymentDiscountWrapper { /// A string identifying the key that is used to generate the signature. /// - /// Keys are generated and downloaded from App Store Connect. See the "KEY ID" - /// column in the App Store Promotions section in App Store Connect to use as - /// the keyIdentifier. + /// Keys are generated and downloaded from App Store Connect. See + /// [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc) + /// for more information. final String keyIdentifier; - /// A universal unique identifier created together with the signature. + /// A universal unique identifier (UUID) created together with the signature. /// - /// The unique nonce should be generated on your server when it creates the - /// `signature` for the payment discount. The nonce can be used once, a new - /// nonce should be created for each payment request. - /// The string representation of the nonce must be lowercase. + /// The UUID should be generated on your server when it creates the + /// `signature` for the payment discount. The UUID can be used once, a new + /// UUID should be created for each payment request. The string representation + /// of the UUID must be lowercase. See + /// [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc) + /// for more information. final String nonce; /// A cryptographically signed string representing the to properties of the @@ -554,7 +557,9 @@ class SKPaymentDiscountWrapper { /// The date and time the signature was created. /// - /// The timestamp should be formatted in Unix epoch time. + /// The timestamp should be formatted in Unix epoch time. See + /// [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers?language=objc) + /// for more information. final int timestamp; @override diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart index 7fc9f6d4b94c..d7a36cdf8c13 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart @@ -299,7 +299,7 @@ class SKProductWrapper { /// An array of subscription offers available for the auto-renewable subscription (available on iOS 12.2 and higher). /// /// This property lists all promotional offers set up in App Store Connect. If - /// no promotional offers have been set up this field returns an empty list. + /// no promotional offers have been set up, this field returns an empty list. /// Each [subscriptionPeriod] of individual discounts are independent of the /// product's [subscriptionPeriod] and their units and duration do not have to /// be matched. From d8b653358fdd1775b1bd11f84527c9465c62a215 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 4 Nov 2021 11:40:42 +0100 Subject: [PATCH 5/8] Use empty const array to initialize discounts --- .../lib/src/store_kit_wrappers/sk_product_wrapper.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart index d7a36cdf8c13..4110754da57b 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart @@ -241,8 +241,8 @@ class SKProductWrapper { required this.price, this.subscriptionPeriod, this.introductoryPrice, - List? discounts, - }) : this.discounts = discounts ?? []; + this.discounts = const [], + }); /// Constructing an instance from a map from the Objective-C layer. /// From ba6dfba9f69605f9c78889e2307d60003bfa39f5 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 4 Nov 2021 17:33:24 +0100 Subject: [PATCH 6/8] Guard against null values for paymentDiscount --- .../example/ios/RunnerTests/TranslatorTests.m | 65 +++++++++++++++++++ .../ios/Classes/FIAObjectTranslator.m | 12 +++- 2 files changed, 76 insertions(+), 1 deletion(-) 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 6ed4a3ba3a9b..5e519405b3c1 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 @@ -200,4 +200,69 @@ - (void)testSKPaymentDiscountFromMap { } } +- (void)testSKPaymentDiscountFromMapMissingIdentifier { + if (@available(iOS 12.2, *)) { + NSDictionary *discountMap = @{ + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : @"this is a encrypted signature", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + } +} + +- (void)testSKPaymentDiscountFromMapMissingKeyIdentifier { + if (@available(iOS 12.2, *)) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : @"this is a encrypted signature", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + } +} + +- (void)testSKPaymentDiscountFromMapMissingNonce { + if (@available(iOS 12.2, *)) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : @"payment_discount_key_identifier", + @"signature" : @"this is a encrypted signature", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + } +} + +- (void)testSKPaymentDiscountFromMapMissingSignature { + if (@available(iOS 12.2, *)) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + } +} + +- (void)testSKPaymentDiscountFromMapMissingTimestamp { + if (@available(iOS 12.2, *)) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : @"this is a encrypted signature", + }; + + XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + } +} + @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 6bbebc5fef05..8259e105975e 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 @@ -209,10 +209,20 @@ + (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront } + (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map { - if (!map) { + if (!map || map.count <= 0) { return nil; } + NSAssert(map[@"identifier"], + @"When specifying a payment discount the 'identifier' field is mandatory."); + NSAssert(map[@"keyIdentifier"], + @"When specifying a payment discount the 'keyIdentifier' field is mandatory."); + NSAssert(map[@"nonce"], @"When specifying a payment discount the 'nonce' field is mandatory."); + NSAssert(map[@"signature"], + @"When specifying a payment discount the 'signature' field is mandatory."); + NSAssert(map[@"timestamp"], + @"When specifying a payment discount the 'timestamp' field is mandatory."); + NSString *identifier = map[@"identifier"]; NSString *keyIdentifier = map[@"keyIdentifier"]; NSUUID *nonce = [[NSUUID alloc] initWithUUIDString:map[@"nonce"]]; From 1867ff472f149832f85eb502a3205ba97a374dfe Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Mon, 8 Nov 2021 21:34:25 +0100 Subject: [PATCH 7/8] Improved testing on illegal values --- .../RunnerTests/InAppPurchasePluginTests.m | 34 +++++ .../example/ios/RunnerTests/TranslatorTests.m | 133 ++++++++++++------ .../ios/Classes/FIAObjectTranslator.h | 3 +- .../ios/Classes/FIAObjectTranslator.m | 53 ++++--- .../ios/Classes/InAppPurchasePlugin.m | 15 +- 5 files changed, 178 insertions(+), 60 deletions(-) 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 fa2d784a1556..84e6f7197f40 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 @@ -203,6 +203,40 @@ - (void)testAddPaymentSuccessWithPaymentDiscount { XCTAssertEqual(transactionForUpdateBlock.transactionState, SKPaymentTransactionStatePurchased); } +- (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), + } + }]; + + [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]; + }]; + + [self waitForExpectations:@[ expectation ] timeout:5]; +} + - (void)testAddPaymentWithNullSandboxArgument { XCTestExpectation* expectation = [self expectationWithDescription:@"result should return success state"]; 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 5e519405b3c1..e014419d76fb 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 @@ -188,8 +188,9 @@ - (void)testSKStorefrontAndSKPaymentTransactionToMap { - (void)testSKPaymentDiscountFromMap { if (@available(iOS 12.2, *)) { + NSString *error = nil; SKPaymentDiscount *paymentDiscount = - [FIAObjectTranslator getSKPaymentDiscountFromMap:self.paymentDiscountMap]; + [FIAObjectTranslator getSKPaymentDiscountFromMap:self.paymentDiscountMap withError:&error]; XCTAssertEqual(paymentDiscount.identifier, self.paymentDiscountMap[@"identifier"]); XCTAssertEqual(paymentDiscount.keyIdentifier, self.paymentDiscountMap[@"keyIdentifier"]); @@ -202,66 +203,116 @@ - (void)testSKPaymentDiscountFromMap { - (void)testSKPaymentDiscountFromMapMissingIdentifier { if (@available(iOS 12.2, *)) { - NSDictionary *discountMap = @{ - @"keyIdentifier" : @"payment_discount_key_identifier", - @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", - @"signature" : @"this is a encrypted signature", - @"timestamp" : @([NSDate date].timeIntervalSince1970), - }; - - XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + NSArray *invalidValues = @[ [NSNull null], @(1), @"" ]; + + for (id value in invalidValues) { + NSDictionary *discountMap = @{ + @"identifier" : value, + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : @"this is a encrypted signature", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + NSString *error = nil; + [FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap withError:&error]; + + XCTAssertNotNil(error); + XCTAssertEqualObjects( + error, @"When specifying a payment discount the 'identifier' field is mandatory."); + } } } - (void)testSKPaymentDiscountFromMapMissingKeyIdentifier { if (@available(iOS 12.2, *)) { - NSDictionary *discountMap = @{ - @"identifier" : @"payment_discount_identifier", - @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", - @"signature" : @"this is a encrypted signature", - @"timestamp" : @([NSDate date].timeIntervalSince1970), - }; - - XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + NSArray *invalidValues = @[ [NSNull null], @(1), @"" ]; + + for (id value in invalidValues) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : value, + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : @"this is a encrypted signature", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + NSString *error = nil; + [FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap withError:&error]; + + XCTAssertNotNil(error); + XCTAssertEqualObjects( + error, @"When specifying a payment discount the 'keyIdentifier' field is mandatory."); + } } } - (void)testSKPaymentDiscountFromMapMissingNonce { if (@available(iOS 12.2, *)) { - NSDictionary *discountMap = @{ - @"identifier" : @"payment_discount_identifier", - @"keyIdentifier" : @"payment_discount_key_identifier", - @"signature" : @"this is a encrypted signature", - @"timestamp" : @([NSDate date].timeIntervalSince1970), - }; - - XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + NSArray *invalidValues = @[ [NSNull null], @(1), @"" ]; + + for (id value in invalidValues) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : value, + @"signature" : @"this is a encrypted signature", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + NSString *error = nil; + [FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap withError:&error]; + + XCTAssertNotNil(error); + XCTAssertEqualObjects(error, + @"When specifying a payment discount the 'nonce' field is mandatory."); + } } } - (void)testSKPaymentDiscountFromMapMissingSignature { if (@available(iOS 12.2, *)) { - NSDictionary *discountMap = @{ - @"identifier" : @"payment_discount_identifier", - @"keyIdentifier" : @"payment_discount_key_identifier", - @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", - @"timestamp" : @([NSDate date].timeIntervalSince1970), - }; - - XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + NSArray *invalidValues = @[ [NSNull null], @(1), @"" ]; + + for (id value in invalidValues) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : value, + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; + + NSString *error = nil; + [FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap withError:&error]; + + XCTAssertNotNil(error); + XCTAssertEqualObjects( + error, @"When specifying a payment discount the 'signature' field is mandatory."); + } } } - (void)testSKPaymentDiscountFromMapMissingTimestamp { if (@available(iOS 12.2, *)) { - NSDictionary *discountMap = @{ - @"identifier" : @"payment_discount_identifier", - @"keyIdentifier" : @"payment_discount_key_identifier", - @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", - @"signature" : @"this is a encrypted signature", - }; - - XCTAssertThrows([FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap]); + NSArray *invalidValues = @[ [NSNull null], @"", @(-1) ]; + + for (id value in invalidValues) { + NSDictionary *discountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : @"this is a encrypted signature", + @"timestamp" : value, + }; + + NSString *error = nil; + [FIAObjectTranslator getSKPaymentDiscountFromMap:discountMap withError:&error]; + + XCTAssertNotNil(error); + XCTAssertEqualObjects( + error, @"When specifying a payment discount the 'timestamp' field is mandatory."); + } } } 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 e843ccd1f703..8b5f358d5ae5 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 @@ -52,7 +52,8 @@ NS_ASSUME_NONNULL_BEGIN API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); // Creates an instance of the SKPaymentDiscount class based on the supplied dictionary. -+ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map API_AVAILABLE(ios(12.2)); ++ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map + withError:(NSString **)error API_AVAILABLE(ios(12.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 8259e105975e..b90a21adb540 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 @@ -208,32 +208,51 @@ + (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront return map; } -+ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map { ++ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map + withError:(NSString **)error { if (!map || map.count <= 0) { return nil; } - NSAssert(map[@"identifier"], - @"When specifying a payment discount the 'identifier' field is mandatory."); - NSAssert(map[@"keyIdentifier"], - @"When specifying a payment discount the 'keyIdentifier' field is mandatory."); - NSAssert(map[@"nonce"], @"When specifying a payment discount the 'nonce' field is mandatory."); - NSAssert(map[@"signature"], - @"When specifying a payment discount the 'signature' field is mandatory."); - NSAssert(map[@"timestamp"], - @"When specifying a payment discount the 'timestamp' field is mandatory."); - NSString *identifier = map[@"identifier"]; NSString *keyIdentifier = map[@"keyIdentifier"]; - NSUUID *nonce = [[NSUUID alloc] initWithUUIDString:map[@"nonce"]]; + NSString *nonce = map[@"nonce"]; NSString *signature = map[@"signature"]; NSNumber *timestamp = map[@"timestamp"]; - SKPaymentDiscount *discount = [[SKPaymentDiscount alloc] initWithIdentifier:identifier - keyIdentifier:keyIdentifier - nonce:nonce - signature:signature - timestamp:timestamp]; + if (!identifier || ![identifier isKindOfClass:NSString.class] || + [identifier isEqualToString:@""]) { + *error = @"When specifying a payment discount the 'identifier' field is mandatory."; + return nil; + } + + if (!keyIdentifier || ![keyIdentifier isKindOfClass:NSString.class] || + [keyIdentifier isEqualToString:@""]) { + *error = @"When specifying a payment discount the 'keyIdentifier' field is mandatory."; + return nil; + } + + if (!nonce || ![nonce isKindOfClass:NSString.class] || [nonce isEqualToString:@""]) { + *error = @"When specifying a payment discount the 'nonce' field is mandatory."; + return nil; + } + + if (!signature || ![signature isKindOfClass:NSString.class] || [signature isEqualToString:@""]) { + *error = @"When specifying a payment discount the 'signature' field is mandatory."; + return nil; + } + + if (!timestamp || ![timestamp isKindOfClass:NSNumber.class] || [timestamp intValue] <= 0) { + *error = @"When specifying a payment discount the 'timestamp' field is mandatory."; + return nil; + } + + SKPaymentDiscount *discount = + [[SKPaymentDiscount alloc] initWithIdentifier:identifier + keyIdentifier:keyIdentifier + nonce:[[NSUUID alloc] initWithUUIDString:nonce] + signature:signature + timestamp:timestamp]; return discount; } 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 6d89c99cf4be..661f57f432d8 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 @@ -204,8 +204,21 @@ - (void)addPayment:(FlutterMethodCall *)call result:(FlutterResult)result { : [simulatesAskToBuyInSandbox boolValue]; if (@available(iOS 12.2, *)) { + NSString *error = nil; SKPaymentDiscount *paymentDiscount = [FIAObjectTranslator - getSKPaymentDiscountFromMap:[paymentMap objectForKey:@"paymentDiscount"]]; + getSKPaymentDiscountFromMap:[paymentMap objectForKey:@"paymentDiscount"] + withError:&error]; + + if (error) { + result([FlutterError + errorWithCode:@"storekit_invalid_payment_discount_object" + message:[NSString stringWithFormat:@"You have requested a payment and specified a " + @"payment discount with invalid properties. %@", + error] + details:call.arguments]); + return; + } + payment.paymentDiscount = paymentDiscount; } From 490009b48839dce2da3d997597e95b84964f5bba Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 9 Nov 2021 09:32:14 +0100 Subject: [PATCH 8/8] Fix analysis warnings --- .../ios/Classes/FIAObjectTranslator.h | 5 +++-- .../ios/Classes/FIAObjectTranslator.m | 20 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) 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 8b5f358d5ae5..eb97ceb44754 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 @@ -52,8 +52,9 @@ NS_ASSUME_NONNULL_BEGIN API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); // Creates an instance of the SKPaymentDiscount class based on the supplied dictionary. -+ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map - withError:(NSString **)error API_AVAILABLE(ios(12.2)); ++ (nullable SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map + withError:(NSString *_Nullable *_Nullable)error + API_AVAILABLE(ios(12.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 b90a21adb540..3ceb512abb10 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 @@ -222,28 +222,38 @@ + (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map if (!identifier || ![identifier isKindOfClass:NSString.class] || [identifier isEqualToString:@""]) { - *error = @"When specifying a payment discount the 'identifier' field is mandatory."; + if (error) { + *error = @"When specifying a payment discount the 'identifier' field is mandatory."; + } return nil; } if (!keyIdentifier || ![keyIdentifier isKindOfClass:NSString.class] || [keyIdentifier isEqualToString:@""]) { - *error = @"When specifying a payment discount the 'keyIdentifier' field is mandatory."; + if (error) { + *error = @"When specifying a payment discount the 'keyIdentifier' field is mandatory."; + } return nil; } if (!nonce || ![nonce isKindOfClass:NSString.class] || [nonce isEqualToString:@""]) { - *error = @"When specifying a payment discount the 'nonce' field is mandatory."; + if (error) { + *error = @"When specifying a payment discount the 'nonce' field is mandatory."; + } return nil; } if (!signature || ![signature isKindOfClass:NSString.class] || [signature isEqualToString:@""]) { - *error = @"When specifying a payment discount the 'signature' field is mandatory."; + if (error) { + *error = @"When specifying a payment discount the 'signature' field is mandatory."; + } return nil; } if (!timestamp || ![timestamp isKindOfClass:NSNumber.class] || [timestamp intValue] <= 0) { - *error = @"When specifying a payment discount the 'timestamp' field is mandatory."; + if (error) { + *error = @"When specifying a payment discount the 'timestamp' field is mandatory."; + } return nil; }