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..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 @@ -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,9 @@ - (void)testAddPaymentSuccessWithMockQueue { SKPaymentTransaction* transaction = transactions[0]; if (transaction.transactionState == SKPaymentTransactionStatePurchased) { transactionForUpdateBlock = transaction; + if (@available(iOS 12.2, *)) { + XCTAssertNil(transaction.payment.paymentDiscount); + } [expectation fulfill]; } } @@ -147,6 +150,93 @@ - (void)testAddPaymentSuccessWithMockQueue { 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]; + } + } + 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)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/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m index 364505d6754a..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 @@ -61,6 +61,14 @@ - (instancetype)initWithMap:(NSDictionary *)map { [self setValue:map[@"subscriptionGroupIdentifier"] ?: [NSNull null] forKey:@"subscriptionGroupIdentifier"]; } + if (@available(iOS 12.2, *)) { + NSMutableArray *discounts = [[NSMutableArray alloc] init]; + 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..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 @@ -14,6 +14,7 @@ @interface TranslatorTest : XCTestCase @property(strong, nonatomic) NSMutableDictionary *productMap; @property(strong, nonatomic) NSDictionary *productResponseMap; @property(strong, nonatomic) NSDictionary *paymentMap; +@property(copy, nonatomic) NSDictionary *paymentDiscountMap; @property(strong, nonatomic) NSDictionary *transactionMap; @property(strong, nonatomic) NSDictionary *errorMap; @property(strong, nonatomic) NSDictionary *localeMap; @@ -45,6 +46,9 @@ - (void)setUp { self.productMap[@"subscriptionPeriod"] = self.periodMap; self.productMap[@"introductoryPrice"] = self.discountMap; } + if (@available(iOS 12.2, *)) { + self.productMap[@"discounts"] = @[ self.discountMap ]; + } if (@available(iOS 12.0, *)) { self.productMap[@"subscriptionGroupIdentifier"] = @"com.group"; @@ -59,6 +63,13 @@ - (void)setUp { @"applicationUsername" : @"app user name", @"simulatesAskToBuyInSandbox" : @(NO) }; + self.paymentDiscountMap = @{ + @"identifier" : @"payment_discount_identifier", + @"keyIdentifier" : @"payment_discount_key_identifier", + @"nonce" : @"d18981e0-9003-4365-98a2-4b90e3b62c52", + @"signature" : @"this is a encrypted signature", + @"timestamp" : @([NSDate date].timeIntervalSince1970), + }; NSDictionary *originalTransactionMap = @{ @"transactionIdentifier" : @"567", @"transactionState" : @(SKPaymentTransactionStatePurchasing), @@ -175,4 +186,134 @@ - (void)testSKStorefrontAndSKPaymentTransactionToMap { } } +- (void)testSKPaymentDiscountFromMap { + if (@available(iOS 12.2, *)) { + NSString *error = nil; + SKPaymentDiscount *paymentDiscount = + [FIAObjectTranslator getSKPaymentDiscountFromMap:self.paymentDiscountMap withError:&error]; + + XCTAssertEqual(paymentDiscount.identifier, self.paymentDiscountMap[@"identifier"]); + XCTAssertEqual(paymentDiscount.keyIdentifier, self.paymentDiscountMap[@"keyIdentifier"]); + XCTAssertEqualObjects(paymentDiscount.nonce, + [[NSUUID alloc] initWithUUIDString:self.paymentDiscountMap[@"nonce"]]); + XCTAssertEqual(paymentDiscount.signature, self.paymentDiscountMap[@"signature"]); + XCTAssertEqual(paymentDiscount.timestamp, self.paymentDiscountMap[@"timestamp"]); + } +} + +- (void)testSKPaymentDiscountFromMapMissingIdentifier { + if (@available(iOS 12.2, *)) { + 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, *)) { + 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, *)) { + 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, *)) { + 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, *)) { + 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."); + } + } +} + @end 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..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 @@ -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; @@ -47,6 +51,11 @@ 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 dictionary. ++ (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 0125604b3b3c..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 @@ -35,6 +35,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 +53,17 @@ + (NSDictionary *)getMapFromSKProductSubscriptionPeriod:(SKProductSubscriptionPe return @{@"numberOfUnits" : @(period.numberOfUnits), @"unit" : @(period.unit)}; } ++ (nonnull NSArray *)getMapArrayFromSKProductDiscounts: + (nonnull NSArray *)productDiscounts { + NSMutableArray *discountsMapArray = [[NSMutableArray alloc] init]; + + for (SKProductDiscount *productDiscount in productDiscounts) { + [discountsMapArray addObject:[FIAObjectTranslator getMapFromSKProductDiscount:productDiscount]]; + } + + return discountsMapArray; +} + + (NSDictionary *)getMapFromSKProductDiscount:(SKProductDiscount *)discount { if (!discount) { return nil; @@ -193,4 +208,63 @@ + (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront return map; } ++ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map + withError:(NSString **)error { + if (!map || map.count <= 0) { + return nil; + } + + NSString *identifier = map[@"identifier"]; + NSString *keyIdentifier = map[@"keyIdentifier"]; + NSString *nonce = map[@"nonce"]; + NSString *signature = map[@"signature"]; + NSNumber *timestamp = map[@"timestamp"]; + + if (!identifier || ![identifier isKindOfClass:NSString.class] || + [identifier isEqualToString:@""]) { + if (error) { + *error = @"When specifying a payment discount the 'identifier' field is mandatory."; + } + return nil; + } + + if (!keyIdentifier || ![keyIdentifier isKindOfClass:NSString.class] || + [keyIdentifier isEqualToString:@""]) { + if (error) { + *error = @"When specifying a payment discount the 'keyIdentifier' field is mandatory."; + } + return nil; + } + + if (!nonce || ![nonce isKindOfClass:NSString.class] || [nonce isEqualToString:@""]) { + if (error) { + *error = @"When specifying a payment discount the 'nonce' field is mandatory."; + } + return nil; + } + + if (!signature || ![signature isKindOfClass:NSString.class] || [signature isEqualToString:@""]) { + if (error) { + *error = @"When specifying a payment discount the 'signature' field is mandatory."; + } + return nil; + } + + if (!timestamp || ![timestamp isKindOfClass:NSNumber.class] || [timestamp intValue] <= 0) { + if (error) { + *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; +} + @end 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..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 @@ -203,6 +203,25 @@ - (void)addPayment:(FlutterMethodCall *)call result:(FlutterResult)result { ? NO : [simulatesAskToBuyInSandbox boolValue]; + if (@available(iOS 12.2, *)) { + NSString *error = nil; + SKPaymentDiscount *paymentDiscount = [FIAObjectTranslator + 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; + } + if (![self.paymentQueueHandler addPayment:payment]) { result([FlutterError errorWithCode:@"storekit_duplicate_product_object" 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..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 @@ -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,14 @@ class SKPaymentWrapper { /// testing. final bool simulatesAskToBuyInSandbox; + /// The details of a discount that should be applied to the payment. + /// + /// 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. If set to `null` no discount will be + /// applied to this payment. + final SKPaymentDiscountWrapper? paymentDiscount; + @override bool operator ==(Object other) { if (identical(other, this)) { @@ -473,3 +483,103 @@ 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. 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 { + /// 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. + 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 + /// [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 (UUID) created together with the signature. + /// + /// 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 + /// 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. 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 + 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..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,6 +241,7 @@ class SKProductWrapper { required this.price, this.subscriptionPeriod, this.introductoryPrice, + this.discounts = const [], }); /// 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!)], }; }