diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index 3d189c6b6b4..d62aa3605ba 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,6 +1,11 @@ -## NEXT +## 0.4.1 * Updates minimum supported SDK version to Flutter 3.27/Dart 3.6. +* Adds **Win Back Offers** support for StoreKit2: + - Includes new `isWinBackOfferEligible` function for eligibility verification +* Adds **Promotional Offers** support in StoreKit2 purchases +* Fixes introductory pricing handling in promotional offers list in StoreKit2 +* Ensures proper `appAccountToken` handling for StoreKit2 purchases ## 0.4.0 diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index 5ce366c8ccb..395148d0660 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -53,7 +53,36 @@ extension InAppPurchasePlugin: InAppPurchase2API { return completion(.failure(error)) } - let result = try await product.purchase(options: []) + var purchaseOptions: Set = [] + + if let appAccountToken = options?.appAccountToken, + let accountTokenUUID = UUID(uuidString: appAccountToken) + { + purchaseOptions.insert(.appAccountToken(accountTokenUUID)) + } + + if #available(iOS 17.4, macOS 14.4, *) { + if let promotionalOffer = options?.promotionalOffer { + purchaseOptions.insert( + .promotionalOffer( + offerID: promotionalOffer.promotionalOfferId, + signature: promotionalOffer.promotionalOfferSignature.convertToSignature + ) + ) + } + } + + if #available(iOS 18.0, macOS 15.0, *) { + if let winBackOfferId = options?.winBackOfferId, + let winBackOffer = product.subscription?.winBackOffers.first(where: { + $0.id == winBackOfferId + }) + { + purchaseOptions.insert(.winBackOffer(winBackOffer)) + } + } + + let result = try await product.purchase(options: purchaseOptions) switch result { case .success(let verification): @@ -88,6 +117,70 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + /// Checks if the user is eligible for a specific win back offer. + /// + /// - Parameters: + /// - productId: The product ID associated with the offer. + /// - offerId: The ID of the win back offer. + /// - completion: Returns `Bool` for eligibility or `Error` on failure. + /// + /// - Availability: iOS 18.0+, macOS 15.0+, Swift 6.0+ (Xcode 16+). + func isWinBackOfferEligible( + productId: String, + offerId: String, + completion: @escaping (Result) -> Void + ) { + if #available(iOS 18.0, macOS 15.0, *) { + Task { + do { + guard let product = try await Product.products(for: [productId]).first else { + completion( + .failure( + PigeonError( + code: "storekit2_failed_to_fetch_product", + message: "Storekit has failed to fetch this product.", + details: "Product ID: \(productId)"))) + return + } + + guard let subscription = product.subscription else { + completion( + .failure( + PigeonError( + code: "storekit2_not_subscription", + message: "Product is not a subscription", + details: "Product ID: \(productId)"))) + return + } + + let isEligible = try await subscription.status.contains { status in + if case .verified(let renewalInfo) = status.renewalInfo { + return renewalInfo.eligibleWinBackOfferIDs.contains(offerId) + } + return false + } + + completion(.success(isEligible)) + + } catch { + completion( + .failure( + PigeonError( + code: "storekit2_eligibility_check_failed", + message: "Failed to check offer eligibility: \(error.localizedDescription)", + details: "Product ID: \(productId), Error: \(error)"))) + } + } + } else { + completion( + .failure( + PigeonError( + code: "storekit2_unsupported_platform_version", + message: "Win back offers require iOS 18+ or macOS 15.0+", + details: nil))) + } + } + /// Wrapper method around StoreKit2's transactions() method /// https://developer.apple.com/documentation/storekit/product/3851116-products func transactions( diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift index 80658bceafa..9be3bb370ca 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift @@ -52,10 +52,23 @@ extension Product.ProductType { @available(iOS 15.0, macOS 12.0, *) extension Product.SubscriptionInfo { var convertToPigeon: SK2SubscriptionInfoMessage { + var allOffers: [SK2SubscriptionOfferMessage] = [] + + if #available(iOS 18.0, macOS 15.0, *) { + allOffers.append(contentsOf: winBackOffers.map { $0.convertToPigeon }) + } + + allOffers.append(contentsOf: promotionalOffers.map { $0.convertToPigeon }) + + if let introductory = introductoryOffer { + allOffers.append(introductory.convertToPigeon) + } + return SK2SubscriptionInfoMessage( - promotionalOffers: promotionalOffers.map({ $0.convertToPigeon }), + promotionalOffers: allOffers, subscriptionGroupID: subscriptionGroupID, - subscriptionPeriod: subscriptionPeriod.convertToPigeon) + subscriptionPeriod: subscriptionPeriod.convertToPigeon + ) } } @@ -90,6 +103,33 @@ extension SK2SubscriptionOfferMessage: Equatable { } } +extension SK2SubscriptionOfferSignatureMessage { + @available(iOS 17.4, macOS 14.4, *) + var convertToSignature: Product.SubscriptionOffer.Signature { + return Product.SubscriptionOffer.Signature( + keyID: keyID, + nonce: nonceAsUUID, + timestamp: Int(timestamp), + signature: signatureAsData + ) + } + + var nonceAsUUID: UUID { + guard let uuid = UUID(uuidString: nonce) else { + fatalError("Invalid UUID format for nonce: \(nonce)") + } + return uuid + } + + var signatureAsData: Data { + guard let data = Data(base64Encoded: signature) else { + fatalError("Invalid Base64 format for signature: \(signature)") + } + return data + } + +} + @available(iOS 15.0, macOS 12.0, *) extension Product.SubscriptionOffer.OfferType { var convertToPigeon: SK2SubscriptionOfferTypeMessage { @@ -99,7 +139,12 @@ extension Product.SubscriptionOffer.OfferType { case .promotional: return SK2SubscriptionOfferTypeMessage.promotional default: - fatalError("An unknown OfferType was passed in") + if #available(iOS 18.0, macOS 15.0, *) { + if self == .winBack { + return SK2SubscriptionOfferTypeMessage.winBack + } + } + fatalError("An unknown or unsupported OfferType was passed in") } } } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift index e53c62bba06..ce95ec1e29c 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift @@ -1,7 +1,7 @@ // 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. -// Autogenerated from Pigeon (v25.1.0), do not edit directly. +// Autogenerated from Pigeon (v25.3.1), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -73,6 +73,70 @@ private func nilOrValue(_ value: Any?) -> T? { return value as! T? } +// swift-format-ignore: AlwaysUseLowerCamelCase +func deepEqualssk2_pigeon(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualssk2_pigeon(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualssk2_pigeon(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +// swift-format-ignore: AlwaysUseLowerCamelCase +func deepHashsk2_pigeon(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashsk2_pigeon(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashsk2_pigeon(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + enum SK2ProductTypeMessage: Int { /// A consumable in-app purchase. case consumable = 0 @@ -87,6 +151,7 @@ enum SK2ProductTypeMessage: Int { enum SK2SubscriptionOfferTypeMessage: Int { case introductory = 0 case promotional = 1 + case winBack = 2 } enum SK2SubscriptionOfferPaymentModeMessage: Int { @@ -109,7 +174,7 @@ enum SK2ProductPurchaseResultMessage: Int { } /// Generated class from Pigeon that represents data sent in messages. -struct SK2SubscriptionOfferMessage { +struct SK2SubscriptionOfferMessage: Hashable { var id: String? = nil var price: Double var type: SK2SubscriptionOfferTypeMessage @@ -145,10 +210,13 @@ struct SK2SubscriptionOfferMessage { paymentMode, ] } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } /// Generated class from Pigeon that represents data sent in messages. -struct SK2SubscriptionPeriodMessage { +struct SK2SubscriptionPeriodMessage: Hashable { /// The number of units that the period represents. var value: Int64 /// The unit of time that this period represents. @@ -170,10 +238,13 @@ struct SK2SubscriptionPeriodMessage { unit, ] } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } /// Generated class from Pigeon that represents data sent in messages. -struct SK2SubscriptionInfoMessage { +struct SK2SubscriptionInfoMessage: Hashable { /// An array of all the promotional offers configured for this subscription. var promotionalOffers: [SK2SubscriptionOfferMessage] /// The group identifier for this subscription. @@ -200,13 +271,16 @@ struct SK2SubscriptionInfoMessage { subscriptionPeriod, ] } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } /// A Pigeon message class representing a Product /// https://developer.apple.com/documentation/storekit/product /// /// Generated class from Pigeon that represents data sent in messages. -struct SK2ProductMessage { +struct SK2ProductMessage: Hashable { /// The unique product identifier. var id: String /// The localized display name of the product, if it exists. @@ -258,10 +332,13 @@ struct SK2ProductMessage { priceLocale, ] } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } /// Generated class from Pigeon that represents data sent in messages. -struct SK2PriceLocaleMessage { +struct SK2PriceLocaleMessage: Hashable { var currencyCode: String var currencySymbol: String @@ -281,33 +358,110 @@ struct SK2PriceLocaleMessage { currencySymbol, ] } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } +} + +/// A Pigeon message class representing a Signature +/// https://developer.apple.com/documentation/storekit/product/subscriptionoffer/signature +/// +/// Generated class from Pigeon that represents data sent in messages. +struct SK2SubscriptionOfferSignatureMessage: Hashable { + var keyID: String + var nonce: String + var timestamp: Int64 + var signature: String + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionOfferSignatureMessage? { + let keyID = pigeonVar_list[0] as! String + let nonce = pigeonVar_list[1] as! String + let timestamp = pigeonVar_list[2] as! Int64 + let signature = pigeonVar_list[3] as! String + + return SK2SubscriptionOfferSignatureMessage( + keyID: keyID, + nonce: nonce, + timestamp: timestamp, + signature: signature + ) + } + func toList() -> [Any?] { + return [ + keyID, + nonce, + timestamp, + signature, + ] + } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SK2SubscriptionOfferPurchaseMessage: Hashable { + var promotionalOfferId: String + var promotionalOfferSignature: SK2SubscriptionOfferSignatureMessage + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionOfferPurchaseMessage? { + let promotionalOfferId = pigeonVar_list[0] as! String + let promotionalOfferSignature = pigeonVar_list[1] as! SK2SubscriptionOfferSignatureMessage + + return SK2SubscriptionOfferPurchaseMessage( + promotionalOfferId: promotionalOfferId, + promotionalOfferSignature: promotionalOfferSignature + ) + } + func toList() -> [Any?] { + return [ + promotionalOfferId, + promotionalOfferSignature, + ] + } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } /// Generated class from Pigeon that represents data sent in messages. -struct SK2ProductPurchaseOptionsMessage { +struct SK2ProductPurchaseOptionsMessage: Hashable { var appAccountToken: String? = nil var quantity: Int64? = nil + var promotionalOffer: SK2SubscriptionOfferPurchaseMessage? = nil + var winBackOfferId: String? = nil // swift-format-ignore: AlwaysUseLowerCamelCase static func fromList(_ pigeonVar_list: [Any?]) -> SK2ProductPurchaseOptionsMessage? { let appAccountToken: String? = nilOrValue(pigeonVar_list[0]) let quantity: Int64? = nilOrValue(pigeonVar_list[1]) + let promotionalOffer: SK2SubscriptionOfferPurchaseMessage? = nilOrValue(pigeonVar_list[2]) + let winBackOfferId: String? = nilOrValue(pigeonVar_list[3]) return SK2ProductPurchaseOptionsMessage( appAccountToken: appAccountToken, - quantity: quantity + quantity: quantity, + promotionalOffer: promotionalOffer, + winBackOfferId: winBackOfferId ) } func toList() -> [Any?] { return [ appAccountToken, quantity, + promotionalOffer, + winBackOfferId, ] } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } /// Generated class from Pigeon that represents data sent in messages. -struct SK2TransactionMessage { +struct SK2TransactionMessage: Hashable { var id: Int64 var originalId: Int64 var productId: String @@ -363,10 +517,13 @@ struct SK2TransactionMessage { jsonRepresentation, ] } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } /// Generated class from Pigeon that represents data sent in messages. -struct SK2ErrorMessage { +struct SK2ErrorMessage: Hashable { var code: Int64 var domain: String var userInfo: [String: Any]? = nil @@ -390,6 +547,12 @@ struct SK2ErrorMessage { userInfo, ] } + static func == (lhs: SK2ErrorMessage, rhs: SK2ErrorMessage) -> Bool { + return deepEqualssk2_pigeon(lhs.toList(), rhs.toList()) + } + func hash(into hasher: inout Hasher) { + deepHashsk2_pigeon(value: toList(), hasher: &hasher) + } } private class Sk2PigeonPigeonCodecReader: FlutterStandardReader { @@ -436,10 +599,14 @@ private class Sk2PigeonPigeonCodecReader: FlutterStandardReader { case 138: return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?]) case 139: - return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?]) case 140: - return SK2TransactionMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?]) case 141: + return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?]) + case 142: + return SK2TransactionMessage.fromList(self.readValue() as! [Any?]) + case 143: return SK2ErrorMessage.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -479,15 +646,21 @@ private class Sk2PigeonPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? SK2PriceLocaleMessage { super.writeByte(138) super.writeValue(value.toList()) - } else if let value = value as? SK2ProductPurchaseOptionsMessage { + } else if let value = value as? SK2SubscriptionOfferSignatureMessage { super.writeByte(139) super.writeValue(value.toList()) - } else if let value = value as? SK2TransactionMessage { + } else if let value = value as? SK2SubscriptionOfferPurchaseMessage { super.writeByte(140) super.writeValue(value.toList()) - } else if let value = value as? SK2ErrorMessage { + } else if let value = value as? SK2ProductPurchaseOptionsMessage { super.writeByte(141) super.writeValue(value.toList()) + } else if let value = value as? SK2TransactionMessage { + super.writeByte(142) + super.writeValue(value.toList()) + } else if let value = value as? SK2ErrorMessage { + super.writeByte(143) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -516,6 +689,8 @@ protocol InAppPurchase2API { func purchase( id: String, options: SK2ProductPurchaseOptionsMessage?, completion: @escaping (Result) -> Void) + func isWinBackOfferEligible( + productId: String, offerId: String, completion: @escaping (Result) -> Void) func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void) func finish(id: Int64, completion: @escaping (Result) -> Void) func startListeningToTransactions() throws @@ -591,6 +766,27 @@ class InAppPurchase2APISetup { } else { purchaseChannel.setMessageHandler(nil) } + let isWinBackOfferEligibleChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isWinBackOfferEligibleChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let productIdArg = args[0] as! String + let offerIdArg = args[1] as! String + api.isWinBackOfferEligible(productId: productIdArg, offerId: offerIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + isWinBackOfferEligibleChannel.setMessageHandler(nil) + } let transactionsChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions\(channelSuffix)", diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj index 1365b55f486..0f822fed53a 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/project.pbxproj @@ -345,10 +345,12 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", "${PODS_CONFIGURATION_BUILD_DIR}/in_app_purchase_storekit/in_app_purchase_storekit_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/shared_preferences_foundation/shared_preferences_foundation_privacy.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/in_app_purchase_storekit_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/shared_preferences_foundation_privacy.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner/Configuration.storekit b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner/Configuration.storekit index 6a9a4779b63..ff9da416492 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner/Configuration.storekit +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner/Configuration.storekit @@ -82,7 +82,10 @@ "name" : "Purchase" }, { - "current" : null, + "current" : { + "index" : 0, + "type" : "verification" + }, "enabled" : false, "name" : "Verification" }, @@ -100,7 +103,10 @@ "name" : "Subscription Status" }, { - "current" : null, + "current" : { + "index" : 3, + "type" : "generic" + }, "enabled" : false, "name" : "App Transaction" }, @@ -115,7 +121,10 @@ "name" : "Refund Request Sheet" }, { - "current" : null, + "current" : { + "index" : 4, + "type" : "generic" + }, "enabled" : false, "name" : "Offer Code Redeem Sheet" } @@ -131,7 +140,15 @@ "subscriptions" : [ { "adHocOffers" : [ - + { + "displayPrice" : "0.99", + "internalID" : "8253DF07", + "numberOfPeriods" : 1, + "offerID" : "subscription_silver_big_promo", + "paymentMode" : "payAsYouGo", + "referenceName" : "Big Promo", + "subscriptionPeriod" : "P1W" + } ], "codeOffers" : [ @@ -154,7 +171,16 @@ "subscriptionGroupID" : "D0FEE8D8", "type" : "RecurringSubscription", "winbackOffers" : [ - + { + "displayPrice" : "0.99", + "internalID" : "91AB5625", + "isEligible" : true, + "numberOfPeriods" : 1, + "offerID" : "subscription_silver_winback_offer", + "paymentMode" : "payAsYouGo", + "referenceName" : "Win Back Offer", + "subscriptionPeriod" : "P1W" + } ] } ] diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_storekit/example/lib/main.dart index 74ea4734676..466968efba6 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/lib/main.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart'; +import 'package:in_app_purchase_storekit/store_kit_2_wrappers.dart'; import 'consumable_store.dart'; import 'example_payment_queue_delegate.dart'; @@ -209,7 +210,7 @@ class _MyAppState extends State<_MyApp> { return Card(child: Column(children: children)); } - Card _buildProductList() { + Widget _buildProductList() { if (_loading) { return const Card( child: ListTile( @@ -219,7 +220,24 @@ class _MyAppState extends State<_MyApp> { if (!_isAvailable) { return const Card(); } - const ListTile productHeader = ListTile(title: Text('Products for Sale')); + const ListTile productHeader = ListTile( + title: Text( + 'Products for Sale', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ); + const ListTile promoHeader = ListTile( + title: Text( + 'Products in promo', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ); final List productList = []; if (_notFoundIds.isNotEmpty) { productList.add(ListTile( @@ -278,9 +296,106 @@ class _MyAppState extends State<_MyApp> { }, )); - return Card( - child: Column( - children: [productHeader, const Divider()] + productList)); + return Column( + children: [ + Card( + child: Column( + children: [ + productHeader, + const Divider(), + ...productList, + ], + ), + ), + Card( + child: Column( + children: [ + promoHeader, + const Divider(), + FutureBuilder>( + future: _buildPromoList(), + builder: (BuildContext context, + AsyncSnapshot> snapshot) { + final List? data = snapshot.data; + + if (data != null) { + return Column( + children: data, + ); + } + + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ], + ); + } + + Future> _buildPromoList() async { + final List promoList = []; + for (final ProductDetails detail in _products) { + if (detail is AppStoreProduct2Details) { + final SK2SubscriptionInfo? subscription = + detail.sk2Product.subscription; + final List offers = + subscription?.promotionalOffers ?? []; + + for (final SK2SubscriptionOffer offer in offers) { + if (offer.type == SK2SubscriptionOfferType.winBack) { + final bool eligible = + await _iapStoreKitPlatform.isWinBackOfferEligible( + detail.id, + offer.id ?? '', + ); + + if (!eligible) { + continue; + } + } + + promoList.add(_buildPromoTile(detail, offer)); + } + } + } + return promoList; + } + + ListTile _buildPromoTile( + ProductDetails productDetails, + SK2SubscriptionOffer offer, + ) { + return ListTile( + title: Text( + '${productDetails.title} [${offer.type.name}]', + ), + subtitle: Text( + productDetails.description, + ), + trailing: TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.green[800], + foregroundColor: Colors.white, + ), + onPressed: () { + final Sk2PurchaseParam purchaseParam = Sk2PurchaseParam.fromOffer( + productDetails: productDetails, + offer: offer, + signature: SK2SubscriptionOfferSignature( + keyID: 'keyID', + nonce: '1ac96421-947d-45e9-a0a0-5bb3a6937284', + timestamp: DateTime.now().millisecondsSinceEpoch, + signature: 'dmFsaWRzaWduYXR1cmU=', + ), + ); + + _iapStoreKitPlatform.buyNonConsumable(purchaseParam: purchaseParam); + }, + child: Text('${productDetails.currencySymbol} ${offer.price}'), + ), + ); } Card _buildConsumableBox() { diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift index 78bc481867c..7fdbf49febc 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -294,6 +294,73 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [expectation], timeout: 5) } + func testPurchaseWithAppAccountToken() async throws { + let expectation = self.expectation(description: "Purchase with appAccountToken should succeed") + + let appAccountToken = UUID().uuidString + let options = SK2ProductPurchaseOptionsMessage( + appAccountToken: appAccountToken, promotionalOffer: nil, winBackOfferId: nil) + + plugin.purchase(id: "consumable", options: options) { result in + switch result { + case .success: + expectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + @available(iOS 17.4, macOS 14.4, *) + func testPurchaseWithPromotionalOffer() async throws { + let expectation = self.expectation(description: "Purchase with promotionalOffer should succeed") + + let promotionalOffer = SK2SubscriptionOfferPurchaseMessage( + promotionalOfferId: "promo_123", + promotionalOfferSignature: SK2SubscriptionOfferSignatureMessage( + keyID: "key123", + nonce: UUID().uuidString, + timestamp: Int64(Date().timeIntervalSince1970), + signature: "dmFsaWRzaWduYXR1cmU=" + ) + ) + let options = SK2ProductPurchaseOptionsMessage( + appAccountToken: nil, promotionalOffer: promotionalOffer, winBackOfferId: nil) + + plugin.purchase(id: "consumable", options: options) { result in + switch result { + case .success: + expectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + @available(iOS 18.0, macOS 15.0, *) + func testPurchaseWithWinBackOffer() async throws { + let expectation = self.expectation(description: "Purchase with winBackOffer should succeed") + + let options = SK2ProductPurchaseOptionsMessage( + appAccountToken: nil, promotionalOffer: nil, + winBackOfferId: "subscription_silver_winback_offer_1month") + + plugin.purchase(id: "subscription_silver", options: options) { result in + switch result { + case .success: + expectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + func testRestoreProductSuccess() async throws { let purchaseExpectation = self.expectation(description: "Purchase request should succeed") let restoreExpectation = self.expectation(description: "Restore request should succeed") @@ -346,4 +413,104 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [finishExpectation], timeout: 5) } + + @available(iOS 18.0, macOS 15.0, *) + func testIsWinBackOfferEligibleEligible() async throws { + let purchaseExpectation = self.expectation(description: "Purchase should succeed") + + plugin.purchase(id: "subscription_silver", options: nil) { result in + switch result { + case .success: + purchaseExpectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + await fulfillment(of: [purchaseExpectation], timeout: 5) + + try session.expireSubscription(productIdentifier: "subscription_silver") + + let expectation = self.expectation(description: "Eligibility check should return true") + + plugin.isWinBackOfferEligible( + productId: "subscription_silver", + offerId: "subscription_silver_winback_offer" + ) { result in + switch result { + case .success(let isEligible): + XCTAssertTrue(isEligible) + expectation.fulfill() + case .failure(let error): + XCTFail("Eligibility check failed: \(error.localizedDescription)") + } + } + + await fulfillment(of: [expectation], timeout: 5) + + } + + @available(iOS 18.0, macOS 15.0, *) + func testIsWinBackOfferEligibleNotEligible() async throws { + let expectation = self.expectation(description: "Eligibility check should return false") + + plugin.isWinBackOfferEligible( + productId: "subscription_silver", + offerId: "invalid_offer_id" + ) { result in + switch result { + case .success(let isEligible): + XCTAssertFalse(isEligible) + expectation.fulfill() + case .failure(let error): + XCTFail("Eligibility check failed: \(error.localizedDescription)") + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + @available(iOS 18.0, macOS 15.0, *) + func testIsWinBackOfferEligibleProductNotFound() async throws { + let expectation = self.expectation(description: "Should throw product not found error") + + plugin.isWinBackOfferEligible( + productId: "invalid_product", + offerId: "winback_offer" + ) { result in + switch result { + case .success: + XCTFail("Should not succeed") + case .failure(let error as PigeonError): + XCTAssertEqual(error.code, "storekit2_failed_to_fetch_product") + expectation.fulfill() + case .failure(let error): + XCTFail("Unexpected error type: \(error)") + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + @available(iOS 18.0, macOS 15.0, *) + func testIsWinBackOfferEligibleNonSubscription() async throws { + let expectation = self.expectation(description: "Should throw non-subscription error") + + plugin.isWinBackOfferEligible( + productId: "consumable", + offerId: "winback_offer" + ) { result in + switch result { + case .success: + XCTFail("Should not succeed") + case .failure(let error as PigeonError): + XCTAssertEqual(error.code, "storekit2_not_subscription") + expectation.fulfill() + case .failure(let error): + XCTFail("Unexpected error type: \(error)") + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/StoreKit2TranslatorTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/StoreKit2TranslatorTests.swift index 6fbd1f8444f..edf936e7347 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/StoreKit2TranslatorTests.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/StoreKit2TranslatorTests.swift @@ -15,8 +15,35 @@ final class StoreKit2TranslatorTests: XCTestCase { private var product: Product! // This is transcribed from the Configuration.storekit file. - var productMessage: SK2ProductMessage = - SK2ProductMessage( + private var productMessage: SK2ProductMessage! + + override func setUp() async throws { + try await super.setUp() + + var promotionalOffers: [SK2SubscriptionOfferMessage] = [ + SK2SubscriptionOfferMessage( + id: "subscription_silver_big_promo", + price: 0.99, + type: .promotional, + period: SK2SubscriptionPeriodMessage(value: 1, unit: .week), + periodCount: 1, + paymentMode: .payAsYouGo) + ] + + if #available(iOS 18.0, macOS 15.0, *) { + promotionalOffers.insert( + SK2SubscriptionOfferMessage( + id: "subscription_silver_winback_offer", + price: 0.99, + type: .winBack, + period: SK2SubscriptionPeriodMessage(value: 1, unit: .week), + periodCount: 1, + paymentMode: .payAsYouGo), + at: 0 + ) + } + + productMessage = SK2ProductMessage( id: "subscription_silver", displayName: "Subscription Silver", description: "A lower level subscription.", @@ -24,16 +51,15 @@ final class StoreKit2TranslatorTests: XCTestCase { displayPrice: "$4.99", type: SK2ProductTypeMessage.autoRenewable, subscription: SK2SubscriptionInfoMessage( - promotionalOffers: [], + promotionalOffers: promotionalOffers, subscriptionGroupID: "D0FEE8D8", subscriptionPeriod: SK2SubscriptionPeriodMessage( value: 1, - unit: SK2SubscriptionPeriodUnitMessage.week)), + unit: SK2SubscriptionPeriodUnitMessage.week + ) + ), priceLocale: SK2PriceLocaleMessage(currencyCode: "USD", currencySymbol: "$")) - override func setUp() async throws { - try await super.setUp() - self.session = try! SKTestSession(configurationFileNamed: "Configuration") self.session.clearTransactions() let receiptManagerStub = FIAPReceiptManagerStub() diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart index 720607978b3..94e3104c380 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart @@ -97,16 +97,88 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { return SKPaymentQueueWrapper.canMakePayments(); } + /// Initiates the purchase flow for a non-consumable product. + /// + /// If StoreKit2 is enabled (`_useStoreKit2` is true), this method uses + /// the StoreKit2 APIs to handle the purchase, including support for + /// win-back offers, promotional offers, or any future StoreKit2-specific + /// offer types. Otherwise, it falls back to StoreKit1 (`SKPaymentQueue`). + /// + /// The [purchaseParam] can be an instance of: + /// - [Sk2PurchaseParam] — to include StoreKit2-specific fields like: + /// - [winBackOfferId]: Applies a win-back offer. + /// - [promotionalOffer]: Applies a promotional offer (requires a valid signature). + /// + /// - [AppStorePurchaseParam] — for StoreKit1 flows using `SKPaymentQueue`. + /// + /// - [PurchaseParam] — the generic, cross-platform parameter for purchases + /// without any platform-specific fields. + /// + /// Returns `true` if the purchase flow was initiated successfully. + /// Note that a `true` return value does not mean the purchase was completed. + /// The final purchase result (success, failure, or pending) is delivered + /// via the purchase updates stream. + /// + /// Throws a [PlatformException] if the purchase could not be initiated due + /// to configuration issues or platform errors. + /// + /// Example: + /// ```dart + /// final productDetails = ...; // Obtained from queryProductDetails + /// + /// // Example using StoreKit 2: + /// final purchaseParamSk2 = Sk2PurchaseParam( + /// productDetails: productDetails, + /// promotionalOffer: myPromotionalOffer, + /// ); + /// await InAppPurchase.instance.buyNonConsumable( + /// purchaseParam: purchaseParamSk2, + /// ); + /// + /// // Example using StoreKit 1 fallback: + /// final purchaseParamSk1 = AppStorePurchaseParam( + /// productDetails: productDetails, + /// quantity: 1, + /// ); + /// await InAppPurchase.instance.buyNonConsumable( + /// purchaseParam: purchaseParamSk1, + /// ); + /// + /// // Example using the generic PurchaseParam (works on any store): + /// final purchaseParamGeneric = PurchaseParam( + /// productDetails: productDetails, + /// ); + /// await InAppPurchase.instance.buyNonConsumable( + /// purchaseParam: purchaseParamGeneric, + /// ); + /// ``` @override Future buyNonConsumable({required PurchaseParam purchaseParam}) async { if (_useStoreKit2) { - final SK2ProductPurchaseOptions options = SK2ProductPurchaseOptions( + final SK2ProductPurchaseOptions options; + + if (purchaseParam is Sk2PurchaseParam) { + options = SK2ProductPurchaseOptions( + appAccountToken: purchaseParam.applicationUserName, + quantity: purchaseParam.quantity, + winBackOfferId: purchaseParam.winBackOfferId, + promotionalOffer: _convertPromotionalOffer( + purchaseParam.promotionalOffer, + ), + ); + } else { + options = SK2ProductPurchaseOptions( quantity: purchaseParam is AppStorePurchaseParam ? purchaseParam.quantity : 1, - appAccountToken: purchaseParam.applicationUserName); - await SK2Product.purchase(purchaseParam.productDetails.id, - options: options); + appAccountToken: purchaseParam.applicationUserName, + ); + } + + await SK2Product.purchase( + purchaseParam.productDetails.id, + options: options, + ); return true; } @@ -124,6 +196,24 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { return true; // There's no error feedback from iOS here to return. } + static SK2SubscriptionOfferPurchaseMessage? _convertPromotionalOffer( + SK2PromotionalOffer? promotionalOffer, + ) { + if (promotionalOffer == null) { + return null; + } + + return SK2SubscriptionOfferPurchaseMessage( + promotionalOfferSignature: SK2SubscriptionOfferSignatureMessage( + keyID: promotionalOffer.signature.keyID, + signature: promotionalOffer.signature.signature, + nonce: promotionalOffer.signature.nonce, + timestamp: promotionalOffer.signature.timestamp, + ), + promotionalOfferId: promotionalOffer.offerId, + ); + } + @override Future buyConsumable( {required PurchaseParam purchaseParam, bool autoConsume = true}) { @@ -264,6 +354,36 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { _useStoreKit2 = !(await SKRequestMaker.supportsStoreKit2()); return _useStoreKit2; } + + /// Checks if the user is eligible for a specific win back offer (StoreKit2 only). + /// + /// Throws [PlatformException] if StoreKit2 is not enabled, if the product is not found, + /// if the product is not a subscription, or if any error occurs during the eligibility check. + /// + /// [PlatformException.code] can be one of: + /// - `storekit2_not_enabled` + /// - `storekit2_unsupported_platform_version` + /// - `storekit2_failed_to_fetch_product` + /// - `storekit2_not_subscription` + /// - `storekit2_eligibility_check_failed` + Future isWinBackOfferEligible( + String productId, + String offerId, + ) async { + if (!_useStoreKit2) { + throw PlatformException( + code: 'storekit2_not_enabled', + message: 'Win back offers require StoreKit2 which is not enabled.', + ); + } + + final bool eligibility = await SK2Product.isWinBackOfferEligible( + productId, + offerId, + ); + + return eligibility; + } } enum _TransactionRestoreState { diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart index a27af1af409..1a095c63fcd 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart @@ -1,7 +1,7 @@ // 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. -// Autogenerated from Pigeon (v25.1.0), do not edit directly. +// Autogenerated from Pigeon (v25.3.1), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -36,11 +36,10 @@ bool _deepEquals(Object? a, Object? b) { .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - final Iterable keys = (a as Map).keys; return a.length == b.length && - keys.every((Object? key) => - (b as Map).containsKey(key) && - _deepEquals(a[key], b[key])); + a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); } return a == b; } @@ -62,6 +61,7 @@ enum SK2ProductTypeMessage { enum SK2SubscriptionOfferTypeMessage { introductory, promotional, + winBack, } enum SK2SubscriptionOfferPaymentModeMessage { @@ -142,12 +142,7 @@ class SK2SubscriptionOfferMessage { if (identical(this, other)) { return true; } - return id == other.id && - price == other.price && - type == other.type && - period == other.period && - periodCount == other.periodCount && - paymentMode == other.paymentMode; + return _deepEquals(encode(), other.encode()); } @override @@ -196,7 +191,7 @@ class SK2SubscriptionPeriodMessage { if (identical(this, other)) { return true; } - return value == other.value && unit == other.unit; + return _deepEquals(encode(), other.encode()); } @override @@ -252,9 +247,7 @@ class SK2SubscriptionInfoMessage { if (identical(this, other)) { return true; } - return _deepEquals(promotionalOffers, other.promotionalOffers) && - subscriptionGroupID == other.subscriptionGroupID && - subscriptionPeriod == other.subscriptionPeriod; + return _deepEquals(encode(), other.encode()); } @override @@ -340,14 +333,7 @@ class SK2ProductMessage { if (identical(this, other)) { return true; } - return id == other.id && - displayName == other.displayName && - description == other.description && - price == other.price && - displayPrice == other.displayPrice && - type == other.type && - subscription == other.subscription && - priceLocale == other.priceLocale; + return _deepEquals(encode(), other.encode()); } @override @@ -393,8 +379,114 @@ class SK2PriceLocaleMessage { if (identical(this, other)) { return true; } - return currencyCode == other.currencyCode && - currencySymbol == other.currencySymbol; + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// A Pigeon message class representing a Signature +/// https://developer.apple.com/documentation/storekit/product/subscriptionoffer/signature +class SK2SubscriptionOfferSignatureMessage { + SK2SubscriptionOfferSignatureMessage({ + required this.keyID, + required this.nonce, + required this.timestamp, + required this.signature, + }); + + String keyID; + + String nonce; + + int timestamp; + + String signature; + + List _toList() { + return [ + keyID, + nonce, + timestamp, + signature, + ]; + } + + Object encode() { + return _toList(); + } + + static SK2SubscriptionOfferSignatureMessage decode(Object result) { + result as List; + return SK2SubscriptionOfferSignatureMessage( + keyID: result[0]! as String, + nonce: result[1]! as String, + timestamp: result[2]! as int, + signature: result[3]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SK2SubscriptionOfferSignatureMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class SK2SubscriptionOfferPurchaseMessage { + SK2SubscriptionOfferPurchaseMessage({ + required this.promotionalOfferId, + required this.promotionalOfferSignature, + }); + + String promotionalOfferId; + + SK2SubscriptionOfferSignatureMessage promotionalOfferSignature; + + List _toList() { + return [ + promotionalOfferId, + promotionalOfferSignature, + ]; + } + + Object encode() { + return _toList(); + } + + static SK2SubscriptionOfferPurchaseMessage decode(Object result) { + result as List; + return SK2SubscriptionOfferPurchaseMessage( + promotionalOfferId: result[0]! as String, + promotionalOfferSignature: + result[1]! as SK2SubscriptionOfferSignatureMessage, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SK2SubscriptionOfferPurchaseMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); } @override @@ -406,16 +498,24 @@ class SK2ProductPurchaseOptionsMessage { SK2ProductPurchaseOptionsMessage({ this.appAccountToken, this.quantity = 1, + this.promotionalOffer, + this.winBackOfferId, }); String? appAccountToken; int? quantity; + SK2SubscriptionOfferPurchaseMessage? promotionalOffer; + + String? winBackOfferId; + List _toList() { return [ appAccountToken, quantity, + promotionalOffer, + winBackOfferId, ]; } @@ -428,6 +528,8 @@ class SK2ProductPurchaseOptionsMessage { return SK2ProductPurchaseOptionsMessage( appAccountToken: result[0] as String?, quantity: result[1] as int?, + promotionalOffer: result[2] as SK2SubscriptionOfferPurchaseMessage?, + winBackOfferId: result[3] as String?, ); } @@ -441,8 +543,7 @@ class SK2ProductPurchaseOptionsMessage { if (identical(this, other)) { return true; } - return appAccountToken == other.appAccountToken && - quantity == other.quantity; + return _deepEquals(encode(), other.encode()); } @override @@ -533,17 +634,7 @@ class SK2TransactionMessage { if (identical(this, other)) { return true; } - return id == other.id && - originalId == other.originalId && - productId == other.productId && - purchaseDate == other.purchaseDate && - expirationDate == other.expirationDate && - purchasedQuantity == other.purchasedQuantity && - appAccountToken == other.appAccountToken && - restoring == other.restoring && - receiptData == other.receiptData && - error == other.error && - jsonRepresentation == other.jsonRepresentation; + return _deepEquals(encode(), other.encode()); } @override @@ -594,9 +685,7 @@ class SK2ErrorMessage { if (identical(this, other)) { return true; } - return code == other.code && - domain == other.domain && - _deepEquals(userInfo, other.userInfo); + return _deepEquals(encode(), other.encode()); } @override @@ -641,15 +730,21 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is SK2PriceLocaleMessage) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is SK2ProductPurchaseOptionsMessage) { + } else if (value is SK2SubscriptionOfferSignatureMessage) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is SK2TransactionMessage) { + } else if (value is SK2SubscriptionOfferPurchaseMessage) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is SK2ErrorMessage) { + } else if (value is SK2ProductPurchaseOptionsMessage) { buffer.putUint8(141); writeValue(buffer, value.encode()); + } else if (value is SK2TransactionMessage) { + buffer.putUint8(142); + writeValue(buffer, value.encode()); + } else if (value is SK2ErrorMessage) { + buffer.putUint8(143); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -692,10 +787,14 @@ class _PigeonCodec extends StandardMessageCodec { case 138: return SK2PriceLocaleMessage.decode(readValue(buffer)!); case 139: - return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferSignatureMessage.decode(readValue(buffer)!); case 140: - return SK2TransactionMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferPurchaseMessage.decode(readValue(buffer)!); case 141: + return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); + case 142: + return SK2TransactionMessage.decode(readValue(buffer)!); + case 143: return SK2ErrorMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -812,6 +911,37 @@ class InAppPurchase2API { } } + Future isWinBackOfferEligible(String productId, String offerId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([productId, offerId]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + Future> transactions() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions$pigeonVar_messageChannelSuffix'; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart index ad5de9c5c77..124f2cd6fef 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/services.dart'; + import '../../store_kit_2_wrappers.dart'; InAppPurchase2API _hostApi = InAppPurchase2API(); @@ -25,7 +26,7 @@ enum SK2ProductType { } extension on SK2ProductTypeMessage { - /// Convert the equivalent pigeon class of [SK2ProductTypeMessage] into an instance of [SK2ProductType] + /// Convert the equivalent pigeon class of [SK2ProductTypeMessage] into an instance of [SK2ProductType]. SK2ProductType convertFromPigeon() { switch (this) { case SK2ProductTypeMessage.autoRenewable: @@ -62,8 +63,11 @@ enum SK2SubscriptionOfferType { /// An introductory offer. introductory, - /// A A promotional offer. - promotional + /// A promotional offer. + promotional, + + /// A win-back offer. + winBack, } extension on SK2SubscriptionOfferTypeMessage { @@ -73,6 +77,8 @@ extension on SK2SubscriptionOfferTypeMessage { return SK2SubscriptionOfferType.introductory; case SK2SubscriptionOfferTypeMessage.promotional: return SK2SubscriptionOfferType.promotional; + case SK2SubscriptionOfferTypeMessage.winBack: + return SK2SubscriptionOfferType.winBack; } } } @@ -147,12 +153,12 @@ class SK2SubscriptionInfo { extension on SK2SubscriptionInfoMessage { SK2SubscriptionInfo convertFromPigeon() { return SK2SubscriptionInfo( - subscriptionGroupID: subscriptionGroupID, - promotionalOffers: promotionalOffers - .map((SK2SubscriptionOfferMessage offer) => - offer.convertFromPigeon()) - .toList(), - subscriptionPeriod: subscriptionPeriod.convertFromPigeon()); + subscriptionGroupID: subscriptionGroupID, + promotionalOffers: promotionalOffers + .map((SK2SubscriptionOfferMessage offer) => offer.convertFromPigeon()) + .toList(), + subscriptionPeriod: subscriptionPeriod.convertFromPigeon(), + ); } } @@ -276,8 +282,13 @@ enum SK2ProductPurchaseResult { /// Wrapper around [PurchaseOption] /// https://developer.apple.com/documentation/storekit/product/purchaseoption class SK2ProductPurchaseOptions { - /// Creates a new instance of [SK2ProductPurchaseOptions] - SK2ProductPurchaseOptions({this.appAccountToken, this.quantity}); + /// Creates a new instance of [SK2ProductPurchaseOptions]. + SK2ProductPurchaseOptions({ + this.appAccountToken, + this.quantity, + this.promotionalOffer, + this.winBackOfferId, + }); /// Sets a UUID to associate the purchase with an account in your system. final String? appAccountToken; @@ -285,10 +296,20 @@ class SK2ProductPurchaseOptions { /// Indicates the quantity of items the customer is purchasing. final int? quantity; - /// Convert to pigeon representation [SK2ProductPurchaseOptionsMessage] + /// Sets a promotional offer to a purchase. + final SK2SubscriptionOfferPurchaseMessage? promotionalOffer; + + /// Sets a win back offer to a purchase. + final String? winBackOfferId; + + /// Convert to pigeon representation [SK2ProductPurchaseOptionsMessage]. SK2ProductPurchaseOptionsMessage convertToPigeon() { return SK2ProductPurchaseOptionsMessage( - appAccountToken: appAccountToken, quantity: quantity); + appAccountToken: appAccountToken, + quantity: quantity, + winBackOfferId: winBackOfferId, + promotionalOffer: promotionalOffer, + ); } } @@ -379,6 +400,19 @@ class SK2Product { return result.convertFromPigeon(); } + /// Checks if the user is eligible for a specific win back offer. + static Future isWinBackOfferEligible( + String productId, + String offerId, + ) async { + final bool result = await _hostApi.isWinBackOfferEligible( + productId, + offerId, + ); + + return result; + } + /// Converts this instance of [SK2Product] to it's pigeon representation [SK2ProductMessage] SK2ProductMessage convertToPigeon() { return SK2ProductMessage( diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/sk2_promotional_offer.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/sk2_promotional_offer.dart new file mode 100644 index 00000000000..0f12901dbc9 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/sk2_promotional_offer.dart @@ -0,0 +1,49 @@ +// 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. + +/// This class encapsulates the metadata required to validate a promotional offer, +/// as specified in the Apple StoreKit documentation: +/// https://developer.apple.com/documentation/storekit/product/subscriptionoffer/signature +class SK2SubscriptionOfferSignature { + /// Creates a new [SK2SubscriptionOfferSignature] object. + SK2SubscriptionOfferSignature({ + required this.keyID, + required this.nonce, + required this.timestamp, + required this.signature, + }); + + /// The key ID of the private key used to generate the signature. + final String keyID; + + /// The nonce used in the signature (a UUID). + final String nonce; + + /// The timestamp when the signature was generated, in milliseconds since 1970. + final int timestamp; + + /// The cryptographic signature of the offer parameters (Base64-encoded string). + final String signature; +} + +/// A wrapper class representing a promotional offer. +/// +/// This class contains the promotional offer identifier and its associated +/// cryptographic signature. +final class SK2PromotionalOffer { + /// Creates a new [SK2PromotionalOffer] object. + SK2PromotionalOffer({ + required this.offerId, + required this.signature, + }); + + /// The promotional offer identifier. + final String offerId; + + /// The cryptographic signature of the promotional offer. + /// + /// This must include metadata such as the `keyID`, `nonce`, `timestamp`, + /// and the Base64-encoded signature. + final SK2SubscriptionOfferSignature signature; +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/sk2_purchase_param.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/sk2_purchase_param.dart new file mode 100644 index 00000000000..906ad0d8f07 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/sk2_purchase_param.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 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../store_kit_2_wrappers.dart'; +import 'sk2_promotional_offer.dart'; + +/// Apple StoreKit2's AppStore specific parameter object for generating a purchase. +class Sk2PurchaseParam extends PurchaseParam { + /// Creates a new [Sk2PurchaseParam] object with the given data. + Sk2PurchaseParam({ + required super.productDetails, + super.applicationUserName, + this.quantity = 1, + this.winBackOfferId, + this.promotionalOffer, + }); + + /// Creates a [Sk2PurchaseParam] from a [ProductDetails] and a [SK2SubscriptionOffer]. + /// + /// Depending on the [offer.type], this factory will: + /// - For [SK2SubscriptionOfferType.winBack]: set [winBackOfferId] to [offer.id]. + /// - For [SK2SubscriptionOfferType.promotional]: set [promotionalOffer] using [offer.id] and [signature]. + /// - For [SK2SubscriptionOfferType.introductory]: create a default [Sk2PurchaseParam]. + /// + /// [productDetails]: The details of the product to purchase. + /// [offer]: The subscription offer to apply. + /// [signature]: The promotional offer signature, required for promotional offers. + factory Sk2PurchaseParam.fromOffer({ + required ProductDetails productDetails, + required SK2SubscriptionOffer offer, + SK2SubscriptionOfferSignature? signature, + }) { + switch (offer.type) { + case SK2SubscriptionOfferType.winBack: + return Sk2PurchaseParam( + productDetails: productDetails, + winBackOfferId: offer.id, + ); + case SK2SubscriptionOfferType.promotional: + return Sk2PurchaseParam( + productDetails: productDetails, + promotionalOffer: SK2PromotionalOffer( + offerId: offer.id ?? '', + signature: signature!, + ), + ); + case SK2SubscriptionOfferType.introductory: + return Sk2PurchaseParam( + productDetails: productDetails, + ); + } + } + + /// Quantity of the product user requested to buy. + final int quantity; + + /// The win back offer identifier to apply to the purchase. + final String? winBackOfferId; + + /// The promotional offer identifier to apply to the purchase. + final SK2PromotionalOffer? promotionalOffer; +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/types.dart index a21bd4b5fbb..0986ff78f2c 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/types.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/types.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // + export 'app_store_product_details.dart'; export 'app_store_purchase_details.dart'; export 'app_store_purchase_param.dart'; +export 'sk2_promotional_offer.dart'; +export 'sk2_purchase_param.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart index da57a65ee6e..7a2a1d9620f 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart @@ -25,7 +25,11 @@ enum SK2ProductTypeMessage { autoRenewable } -enum SK2SubscriptionOfferTypeMessage { introductory, promotional } +enum SK2SubscriptionOfferTypeMessage { + introductory, + promotional, + winBack, +} enum SK2SubscriptionOfferPaymentModeMessage { payAsYouGo, @@ -127,13 +131,44 @@ class SK2PriceLocaleMessage { final String currencySymbol; } +/// A Pigeon message class representing a Signature +/// https://developer.apple.com/documentation/storekit/product/subscriptionoffer/signature +class SK2SubscriptionOfferSignatureMessage { + SK2SubscriptionOfferSignatureMessage({ + required this.keyID, + required this.nonce, + required this.timestamp, + required this.signature, + }); + + final String keyID; + final String nonce; + final int timestamp; + final String signature; +} + +class SK2SubscriptionOfferPurchaseMessage { + SK2SubscriptionOfferPurchaseMessage({ + required this.promotionalOfferId, + required this.promotionalOfferSignature, + }); + + final String promotionalOfferId; + final SK2SubscriptionOfferSignatureMessage promotionalOfferSignature; +} + class SK2ProductPurchaseOptionsMessage { SK2ProductPurchaseOptionsMessage({ this.appAccountToken, this.quantity = 1, + this.promotionalOffer, + this.winBackOfferId, }); + final String? appAccountToken; final int? quantity; + final SK2SubscriptionOfferPurchaseMessage? promotionalOffer; + final String? winBackOfferId; } class SK2TransactionMessage { @@ -187,6 +222,9 @@ abstract class InAppPurchase2API { SK2ProductPurchaseResultMessage purchase(String id, {SK2ProductPurchaseOptionsMessage? options}); + @async + bool isWinBackOfferEligible(String productId, String offerId); + @async List transactions(); diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml index 5df44552fe7..23acddf5aad 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_storekit description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.4.0 +version: 0.4.1 environment: sdk: ^3.6.0 diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart index f854e78f613..450e6a86a2e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart @@ -51,6 +51,7 @@ class FakeStoreKitPlatform implements TestInAppPurchaseApi { productWrapperMap['localizedDescription'] = null; } validProducts[validID] = SKProductWrapper.fromJson(productWrapperMap); + shouldStoreKit2BeEnabled = true; } finishedTransactions = []; @@ -298,6 +299,8 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { PlatformException? queryProductException; bool isListenerRegistered = false; + SK2ProductPurchaseOptionsMessage? lastPurchaseOptions; + Map> eligibleWinBackOffers = >{}; void reset() { validProductIDs = {'123', '456'}; @@ -314,6 +317,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$')); validProducts[validID] = product; } + eligibleWinBackOffers = >{}; } SK2TransactionMessage createRestoredTransaction( @@ -356,6 +360,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { @override Future purchase(String id, {SK2ProductPurchaseOptionsMessage? options}) { + lastPurchaseOptions = options; final SK2TransactionMessage transaction = createPendingTransaction(id); InAppPurchaseStoreKitPlatform.sk2TransactionObserver @@ -405,6 +410,30 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { Future sync() { return Future.value(); } + + @override + Future isWinBackOfferEligible( + String productId, + String offerId, + ) async { + if (!validProductIDs.contains(productId)) { + throw PlatformException( + code: 'storekit2_failed_to_fetch_product', + message: 'StoreKit failed to fetch product', + details: 'Product ID: $productId', + ); + } + + if (validProducts[productId]?.type != SK2ProductType.autoRenewable) { + throw PlatformException( + code: 'storekit2_not_subscription', + message: 'Product is not a subscription', + details: 'Product ID: $productId', + ); + } + + return eligibleWinBackOffers[productId]?.contains(offerId) ?? false; + } } SK2TransactionMessage createPendingTransaction(String id, {int quantity = 1}) { diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart index 9277127d60f..ba072db74f2 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart @@ -153,6 +153,81 @@ void main() { purchaseParam: purchaseParam, autoConsume: false), throwsA(isInstanceOf())); }); + + test('should process Sk2PurchaseParam with winBackOfferId only', () async { + final Sk2PurchaseParam purchaseParam = Sk2PurchaseParam( + productDetails: + AppStoreProduct2Details.fromSK2Product(dummyProductWrapper), + applicationUserName: 'testUser', + winBackOfferId: 'winBack123', + ); + + await iapStoreKitPlatform.buyNonConsumable(purchaseParam: purchaseParam); + + final SK2ProductPurchaseOptionsMessage lastPurchaseOptions = + fakeStoreKit2Platform.lastPurchaseOptions!; + + expect(lastPurchaseOptions.appAccountToken, 'testUser'); + expect(lastPurchaseOptions.quantity, 1); + expect(lastPurchaseOptions.winBackOfferId, 'winBack123'); + expect(lastPurchaseOptions.promotionalOffer, isNull); + }); + + test('should process Sk2PurchaseParam with promotionalOffer only', + () async { + final SK2SubscriptionOfferSignature fakeSignature = + SK2SubscriptionOfferSignature( + keyID: 'key123', + signature: 'signature123', + nonce: 'nonce123', + timestamp: 1234567890, + ); + + final Sk2PurchaseParam purchaseParam = Sk2PurchaseParam( + productDetails: + AppStoreProduct2Details.fromSK2Product(dummyProductWrapper), + applicationUserName: 'testUser', + quantity: 2, + promotionalOffer: SK2PromotionalOffer( + signature: fakeSignature, + offerId: 'promo123', + ), + ); + + await iapStoreKitPlatform.buyNonConsumable(purchaseParam: purchaseParam); + + final SK2ProductPurchaseOptionsMessage lastPurchaseOptions = + fakeStoreKit2Platform.lastPurchaseOptions!; + + expect(lastPurchaseOptions.appAccountToken, 'testUser'); + expect(lastPurchaseOptions.quantity, 2); + expect( + lastPurchaseOptions.promotionalOffer!.promotionalOfferId, 'promo123'); + expect( + lastPurchaseOptions.promotionalOffer!.promotionalOfferSignature.keyID, + 'key123'); + expect(lastPurchaseOptions.winBackOfferId, isNull); + }); + + test( + 'should process Sk2PurchaseParam with no winBackOfferId or promotionalOffer', + () async { + final Sk2PurchaseParam purchaseParam = Sk2PurchaseParam( + productDetails: + AppStoreProduct2Details.fromSK2Product(dummyProductWrapper), + applicationUserName: 'testUser', + ); + + await iapStoreKitPlatform.buyNonConsumable(purchaseParam: purchaseParam); + + final SK2ProductPurchaseOptionsMessage lastPurchaseOptions = + fakeStoreKit2Platform.lastPurchaseOptions!; + + expect(lastPurchaseOptions.appAccountToken, 'testUser'); + expect(lastPurchaseOptions.quantity, 1); + expect(lastPurchaseOptions.winBackOfferId, isNull); + expect(lastPurchaseOptions.promotionalOffer, isNull); + }); }); group('restore purchases', () { @@ -199,4 +274,132 @@ void main() { expect(countryCode, expectedCountryCode); }); }); + + group('win back offers eligibility', () { + late FakeStoreKit2Platform fakeStoreKit2Platform; + + setUp(() async { + fakeStoreKit2Platform = FakeStoreKit2Platform(); + fakeStoreKit2Platform.reset(); + TestInAppPurchase2Api.setUp(fakeStoreKit2Platform); + await InAppPurchaseStoreKitPlatform.enableStoreKit2(); + }); + + test('should return true when offer is eligible', () async { + fakeStoreKit2Platform.validProductIDs = {'sub1'}; + fakeStoreKit2Platform.eligibleWinBackOffers['sub1'] = { + 'winback1' + }; + fakeStoreKit2Platform.validProducts['sub1'] = SK2Product( + id: 'sub1', + displayName: 'Subscription', + displayPrice: r'$9.99', + description: 'Monthly subscription', + price: 9.99, + type: SK2ProductType.autoRenewable, + subscription: const SK2SubscriptionInfo( + subscriptionGroupID: 'group1', + promotionalOffers: [], + subscriptionPeriod: SK2SubscriptionPeriod( + value: 1, + unit: SK2SubscriptionPeriodUnit.month, + ), + ), + priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'), + ); + + final bool result = await iapStoreKitPlatform.isWinBackOfferEligible( + 'sub1', + 'winback1', + ); + + expect(result, isTrue); + }); + + test('should return false when offer is not eligible', () async { + fakeStoreKit2Platform.validProductIDs = {'sub1'}; + fakeStoreKit2Platform.eligibleWinBackOffers = >{}; + fakeStoreKit2Platform.validProducts['sub1'] = SK2Product( + id: 'sub1', + displayName: 'Subscription', + displayPrice: r'$9.99', + description: 'Monthly subscription', + price: 9.99, + type: SK2ProductType.autoRenewable, + subscription: const SK2SubscriptionInfo( + subscriptionGroupID: 'group1', + promotionalOffers: [], + subscriptionPeriod: SK2SubscriptionPeriod( + value: 1, + unit: SK2SubscriptionPeriodUnit.month, + ), + ), + priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'), + ); + + final bool result = await iapStoreKitPlatform.isWinBackOfferEligible( + 'sub1', + 'winback1', + ); + + expect(result, isFalse); + }); + + test('should throw product not found error for invalid product', () async { + expect( + () => iapStoreKitPlatform.isWinBackOfferEligible( + 'invalid_product', + 'winback1', + ), + throwsA(isA().having( + (PlatformException e) => e.code, + 'code', + 'storekit2_failed_to_fetch_product', + )), + ); + }); + + test('should throw subscription error for non-subscription product', + () async { + fakeStoreKit2Platform.validProductIDs = {'consumable1'}; + fakeStoreKit2Platform.validProducts['consumable1'] = SK2Product( + id: 'consumable1', + displayName: 'Coins', + displayPrice: r'$0.99', + description: 'Game currency', + price: 0.99, + type: SK2ProductType.consumable, + priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'), + ); + + expect( + () => iapStoreKitPlatform.isWinBackOfferEligible( + 'consumable1', + 'winback1', + ), + throwsA(isA().having( + (PlatformException e) => e.code, + 'code', + 'storekit2_not_subscription', + )), + ); + }); + + test('should throw platform exception when StoreKit2 is not supported', + () async { + await InAppPurchaseStoreKitPlatform.enableStoreKit1(); + + expect( + () => iapStoreKitPlatform.isWinBackOfferEligible( + 'sub1', + 'winback1', + ), + throwsA(isA().having( + (PlatformException e) => e.code, + 'code', + 'storekit2_not_enabled', + )), + ); + }); + }); } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart index 57399529e55..cc6fb75e5fe 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart @@ -1,7 +1,7 @@ // 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. -// Autogenerated from Pigeon (v25.1.0), do not edit directly. +// Autogenerated from Pigeon (v25.3.1), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, unnecessary_import, no_leading_underscores_for_local_identifiers // ignore_for_file: avoid_relative_lib_imports @@ -50,15 +50,21 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is SK2PriceLocaleMessage) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is SK2ProductPurchaseOptionsMessage) { + } else if (value is SK2SubscriptionOfferSignatureMessage) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is SK2TransactionMessage) { + } else if (value is SK2SubscriptionOfferPurchaseMessage) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is SK2ErrorMessage) { + } else if (value is SK2ProductPurchaseOptionsMessage) { buffer.putUint8(141); writeValue(buffer, value.encode()); + } else if (value is SK2TransactionMessage) { + buffer.putUint8(142); + writeValue(buffer, value.encode()); + } else if (value is SK2ErrorMessage) { + buffer.putUint8(143); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -101,10 +107,14 @@ class _PigeonCodec extends StandardMessageCodec { case 138: return SK2PriceLocaleMessage.decode(readValue(buffer)!); case 139: - return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferSignatureMessage.decode(readValue(buffer)!); case 140: - return SK2TransactionMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferPurchaseMessage.decode(readValue(buffer)!); case 141: + return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); + case 142: + return SK2TransactionMessage.decode(readValue(buffer)!); + case 143: return SK2ErrorMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -124,6 +134,8 @@ abstract class TestInAppPurchase2Api { Future purchase(String id, {SK2ProductPurchaseOptionsMessage? options}); + Future isWinBackOfferEligible(String productId, String offerId); + Future> transactions(); Future finish(int id); @@ -240,6 +252,42 @@ abstract class TestInAppPurchase2Api { }); } } + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible was null.'); + final List args = (message as List?)!; + final String? arg_productId = (args[0] as String?); + assert(arg_productId != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible was null, expected non-null String.'); + final String? arg_offerId = (args[1] as String?); + assert(arg_offerId != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible was null, expected non-null String.'); + try { + final bool output = + await api.isWinBackOfferEligible(arg_productId!, arg_offerId!); + return [output]; + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } { final BasicMessageChannel< Object?> pigeonVar_channel = BasicMessageChannel<