From d472256ed9113baf8c8bde6e2d863d73a5e4cc78 Mon Sep 17 00:00:00 2001 From: LouiseHsu Date: Thu, 29 Aug 2024 14:32:24 -0700 Subject: [PATCH] [in_app_purchase_storekit] Add storekit 2 support for canMakePayments and products (#7473) This PR contains support for StoreKit2's [canMakePayments](https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments) and [products](https://developer.apple.com/documentation/storekit/product/3851116-products). This also contains basic scaffolding for SK2 support, such as pigeon translators. Part of https://github.com/flutter/flutter/issues/116383 --- .../in_app_purchase_storekit/CHANGELOG.md | 4 + .../darwin/Classes/InAppPurchasePlugin.swift | 3 + .../StoreKit2/InAppPurchaseStoreKit2.swift | 37 ++ .../StoreKit2/StoreKit2Translators.swift | 169 +++++++ .../Classes/StoreKit2/sk2_pigeon.g.swift | 423 ++++++++++++++++++ .../ios/Runner.xcodeproj/project.pbxproj | 16 +- .../example/ios/Runner/Configuration.storekit | 5 +- .../InAppPurchaseStoreKit2PluginTests.swift | 99 ++++ .../StoreKit2TranslatorTests.swift | 82 ++++ .../in_app_purchase_storekit_platform.dart | 48 +- .../lib/src/sk2_pigeon.g.dart | 416 +++++++++++++++++ .../sk2_appstore_wrapper.dart | 18 + .../sk2_product_wrapper.dart | 352 +++++++++++++++ .../src/types/app_store_product_details.dart | 36 ++ .../lib/store_kit_2_wrappers.dart | 7 + .../pigeons/sk2_pigeon.dart | 141 ++++++ .../in_app_purchase_storekit/pubspec.yaml | 4 +- .../test/fakes/fake_storekit_platform.dart | 51 +++ ...app_purchase_storekit_2_platform_test.dart | 73 +++ .../test/sk2_test_api.g.dart | 169 +++++++ 20 files changed, 2146 insertions(+), 7 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/InAppPurchaseStoreKit2.swift create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/StoreKit2Translators.swift create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/sk2_pigeon.g.swift create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/StoreKit2TranslatorTests.swift create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_appstore_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/lib/store_kit_2_wrappers.dart create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart create mode 100644 packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart 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 19281b518933..f072c0927709 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.18+1 + +* Adds support for StoreKit2's `canMakePayments` and `products` + ## 0.3.18 * Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.swift index dad613571388..a591739eb693 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.swift @@ -41,6 +41,9 @@ public class InAppPurchasePlugin: NSObject, FlutterPlugin, InAppPurchaseAPI { registrar.addMethodCallDelegate(instance, channel: channel) registrar.addApplicationDelegate(instance) SetUpInAppPurchaseAPI(messenger, instance) + if #available(iOS 15.0, macOS 12.0, *) { + InAppPurchase2APISetup.setUp(binaryMessenger: messenger, api: instance) + } } // This init is used for tests diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/InAppPurchaseStoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/InAppPurchaseStoreKit2.swift new file mode 100644 index 000000000000..7ab0405ae674 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/InAppPurchaseStoreKit2.swift @@ -0,0 +1,37 @@ +// 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. + +@available(iOS 15.0, macOS 12.0, *) +extension InAppPurchasePlugin: InAppPurchase2API { + // MARK: - Pigeon Functions + + // Wrapper method around StoreKit2's canMakePayments() method + // https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments + func canMakePayments() throws -> Bool { + return AppStore.canMakePayments + } + + // Wrapper method around StoreKit2's products() method + // https://developer.apple.com/documentation/storekit/product/3851116-products + func products( + identifiers: [String], completion: @escaping (Result<[SK2ProductMessage], Error>) -> Void + ) { + Task { + do { + let products = try await Product.products(for: identifiers) + let productMessages = products.map { + $0.convertToPigeon + } + completion(.success(productMessages)) + } catch { + completion( + .failure( + PigeonError( + code: "storekit2_products_error", + message: error.localizedDescription, + details: error.localizedDescription))) + } + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/StoreKit2Translators.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/StoreKit2Translators.swift new file mode 100644 index 000000000000..aed0059733e0 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/StoreKit2Translators.swift @@ -0,0 +1,169 @@ +// 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 Foundation +import StoreKit + +@available(iOS 15.0, macOS 12.0, *) +extension Product { + var convertToPigeon: SK2ProductMessage { + + return SK2ProductMessage( + id: id, + displayName: displayName, + description: description, + price: NSDecimalNumber(decimal: price).doubleValue, + displayPrice: displayPrice, + type: type.convertToPigeon, + subscription: subscription?.convertToPigeon, + priceLocale: priceFormatStyle.locale.convertToPigeon + ) + } +} + +extension SK2ProductMessage: Equatable { + static func == (lhs: SK2ProductMessage, rhs: SK2ProductMessage) -> Bool { + return lhs.id == rhs.id && lhs.displayName == rhs.displayName + && lhs.description == rhs.description && lhs.price == rhs.price + && lhs.displayPrice == rhs.displayPrice && lhs.type == rhs.type + && lhs.subscription == rhs.subscription && lhs.priceLocale == rhs.priceLocale + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Product.ProductType { + var convertToPigeon: SK2ProductTypeMessage { + switch self { + case Product.ProductType.autoRenewable: + return SK2ProductTypeMessage.autoRenewable + case Product.ProductType.consumable: + return SK2ProductTypeMessage.consumable + case Product.ProductType.nonConsumable: + return SK2ProductTypeMessage.nonConsumable + case Product.ProductType.nonRenewable: + return SK2ProductTypeMessage.nonRenewable + default: + fatalError("An unknown ProductType was passed in") + } + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Product.SubscriptionInfo { + var convertToPigeon: SK2SubscriptionInfoMessage { + return SK2SubscriptionInfoMessage( + promotionalOffers: promotionalOffers.map({ $0.convertToPigeon }), + subscriptionGroupID: subscriptionGroupID, + subscriptionPeriod: subscriptionPeriod.convertToPigeon) + } +} + +extension SK2SubscriptionInfoMessage: Equatable { + static func == (lhs: SK2SubscriptionInfoMessage, rhs: SK2SubscriptionInfoMessage) -> Bool { + return lhs.promotionalOffers == rhs.promotionalOffers + && lhs.subscriptionGroupID == rhs.subscriptionGroupID + && lhs.subscriptionPeriod == rhs.subscriptionPeriod + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Product.SubscriptionOffer { + var convertToPigeon: SK2SubscriptionOfferMessage { + return SK2SubscriptionOfferMessage( + /// ID is always `nil` for introductory offers and never `nil` for other offer types. + id: id, + price: NSDecimalNumber(decimal: price).doubleValue, + type: type.convertToPigeon, + period: period.convertToPigeon, + periodCount: Int64(periodCount), + paymentMode: paymentMode.convertToPigeon + ) + } +} + +extension SK2SubscriptionOfferMessage: Equatable { + static func == (lhs: SK2SubscriptionOfferMessage, rhs: SK2SubscriptionOfferMessage) -> Bool { + return lhs.id == rhs.id && lhs.price == rhs.price && lhs.type == rhs.type + && lhs.period == rhs.period && lhs.periodCount == rhs.periodCount + && lhs.paymentMode == rhs.paymentMode + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Product.SubscriptionOffer.OfferType { + var convertToPigeon: SK2SubscriptionOfferTypeMessage { + switch self { + case .introductory: + return SK2SubscriptionOfferTypeMessage.introductory + case .promotional: + return SK2SubscriptionOfferTypeMessage.promotional + default: + fatalError("An unknown OfferType was passed in") + } + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Product.SubscriptionPeriod { + var convertToPigeon: SK2SubscriptionPeriodMessage { + return SK2SubscriptionPeriodMessage( + value: Int64(value), + unit: unit.convertToPigeon) + } +} + +extension SK2SubscriptionPeriodMessage: Equatable { + static func == (lhs: SK2SubscriptionPeriodMessage, rhs: SK2SubscriptionPeriodMessage) -> Bool { + return lhs.value == rhs.value && lhs.unit == rhs.unit + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Product.SubscriptionPeriod.Unit { + var convertToPigeon: SK2SubscriptionPeriodUnitMessage { + switch self { + case .day: + return SK2SubscriptionPeriodUnitMessage.day + case .week: + return SK2SubscriptionPeriodUnitMessage.week + case .month: + return SK2SubscriptionPeriodUnitMessage.month + case .year: + return SK2SubscriptionPeriodUnitMessage.year + @unknown default: + fatalError("unknown SubscriptionPeriodUnit encountered") + } + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Product.SubscriptionOffer.PaymentMode { + var convertToPigeon: SK2SubscriptionOfferPaymentModeMessage { + switch self { + case .freeTrial: + return SK2SubscriptionOfferPaymentModeMessage.freeTrial + case .payUpFront: + return SK2SubscriptionOfferPaymentModeMessage.payUpFront + case .payAsYouGo: + return SK2SubscriptionOfferPaymentModeMessage.payAsYouGo + default: + fatalError("Encountered an unknown PaymentMode") + } + } +} + +extension Locale { + var convertToPigeon: SK2PriceLocaleMessage { + return SK2PriceLocaleMessage( + currencyCode: currencyCode ?? "", + currencySymbol: currencySymbol ?? "" + ) + } +} + +extension SK2PriceLocaleMessage: Equatable { + static func == (lhs: SK2PriceLocaleMessage, rhs: SK2PriceLocaleMessage) -> Bool { + return lhs.currencyCode == rhs.currencyCode && lhs.currencySymbol == rhs.currencySymbol + } +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/sk2_pigeon.g.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/sk2_pigeon.g.swift new file mode 100644 index 000000000000..119fb288f364 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/sk2_pigeon.g.swift @@ -0,0 +1,423 @@ +// 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 (v22.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Any? + + init(code: String, message: String?, details: Any?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +enum SK2ProductTypeMessage: Int { + /// A consumable in-app purchase. + case consumable = 0 + /// A non-consumable in-app purchase. + case nonConsumable = 1 + /// A non-renewing subscription. + case nonRenewable = 2 + /// An auto-renewable subscription. + case autoRenewable = 3 +} + +enum SK2SubscriptionOfferTypeMessage: Int { + case introductory = 0 + case promotional = 1 +} + +enum SK2SubscriptionOfferPaymentModeMessage: Int { + case payAsYouGo = 0 + case payUpFront = 1 + case freeTrial = 2 +} + +enum SK2SubscriptionPeriodUnitMessage: Int { + case day = 0 + case week = 1 + case month = 2 + case year = 3 +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SK2SubscriptionOfferMessage { + var id: String? = nil + var price: Double + var type: SK2SubscriptionOfferTypeMessage + var period: SK2SubscriptionPeriodMessage + var periodCount: Int64 + var paymentMode: SK2SubscriptionOfferPaymentModeMessage + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionOfferMessage? { + let id: String? = nilOrValue(pigeonVar_list[0]) + let price = pigeonVar_list[1] as! Double + let type = pigeonVar_list[2] as! SK2SubscriptionOfferTypeMessage + let period = pigeonVar_list[3] as! SK2SubscriptionPeriodMessage + let periodCount = + pigeonVar_list[4] is Int64 ? pigeonVar_list[4] as! Int64 : Int64(pigeonVar_list[4] as! Int32) + let paymentMode = pigeonVar_list[5] as! SK2SubscriptionOfferPaymentModeMessage + + return SK2SubscriptionOfferMessage( + id: id, + price: price, + type: type, + period: period, + periodCount: periodCount, + paymentMode: paymentMode + ) + } + func toList() -> [Any?] { + return [ + id, + price, + type, + period, + periodCount, + paymentMode, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SK2SubscriptionPeriodMessage { + /// The number of units that the period represents. + var value: Int64 + /// The unit of time that this period represents. + var unit: SK2SubscriptionPeriodUnitMessage + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionPeriodMessage? { + let value = + pigeonVar_list[0] is Int64 ? pigeonVar_list[0] as! Int64 : Int64(pigeonVar_list[0] as! Int32) + let unit = pigeonVar_list[1] as! SK2SubscriptionPeriodUnitMessage + + return SK2SubscriptionPeriodMessage( + value: value, + unit: unit + ) + } + func toList() -> [Any?] { + return [ + value, + unit, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SK2SubscriptionInfoMessage { + /// An array of all the promotional offers configured for this subscription. + /// This should be List but pigeon doesnt support + /// null-safe generics. https://github.com/flutter/flutter/issues/97848 + var promotionalOffers: [SK2SubscriptionOfferMessage?] + /// The group identifier for this subscription. + var subscriptionGroupID: String + /// The duration that this subscription lasts before auto-renewing. + var subscriptionPeriod: SK2SubscriptionPeriodMessage + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionInfoMessage? { + let promotionalOffers = pigeonVar_list[0] as! [SK2SubscriptionOfferMessage?] + let subscriptionGroupID = pigeonVar_list[1] as! String + let subscriptionPeriod = pigeonVar_list[2] as! SK2SubscriptionPeriodMessage + + return SK2SubscriptionInfoMessage( + promotionalOffers: promotionalOffers, + subscriptionGroupID: subscriptionGroupID, + subscriptionPeriod: subscriptionPeriod + ) + } + func toList() -> [Any?] { + return [ + promotionalOffers, + subscriptionGroupID, + subscriptionPeriod, + ] + } +} + +/// 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 { + /// The unique product identifier. + var id: String + /// The localized display name of the product, if it exists. + var displayName: String + /// The localized description of the product. + var description: String + /// The localized string representation of the product price, suitable for display. + var price: Double + /// The localized price of the product as a string. + var displayPrice: String + /// The types of in-app purchases. + var type: SK2ProductTypeMessage + /// The subscription information for an auto-renewable subscription. + var subscription: SK2SubscriptionInfoMessage? = nil + /// The currency and locale information for this product + var priceLocale: SK2PriceLocaleMessage + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2ProductMessage? { + let id = pigeonVar_list[0] as! String + let displayName = pigeonVar_list[1] as! String + let description = pigeonVar_list[2] as! String + let price = pigeonVar_list[3] as! Double + let displayPrice = pigeonVar_list[4] as! String + let type = pigeonVar_list[5] as! SK2ProductTypeMessage + let subscription: SK2SubscriptionInfoMessage? = nilOrValue(pigeonVar_list[6]) + let priceLocale = pigeonVar_list[7] as! SK2PriceLocaleMessage + + return SK2ProductMessage( + id: id, + displayName: displayName, + description: description, + price: price, + displayPrice: displayPrice, + type: type, + subscription: subscription, + priceLocale: priceLocale + ) + } + func toList() -> [Any?] { + return [ + id, + displayName, + description, + price, + displayPrice, + type, + subscription, + priceLocale, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SK2PriceLocaleMessage { + var currencyCode: String + var currencySymbol: String + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2PriceLocaleMessage? { + let currencyCode = pigeonVar_list[0] as! String + let currencySymbol = pigeonVar_list[1] as! String + + return SK2PriceLocaleMessage( + currencyCode: currencyCode, + currencySymbol: currencySymbol + ) + } + func toList() -> [Any?] { + return [ + currencyCode, + currencySymbol, + ] + } +} + +private class sk2_pigeonPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as? Int) + if let enumResultAsInt = enumResultAsInt { + return SK2ProductTypeMessage(rawValue: enumResultAsInt) + } + return nil + case 130: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as? Int) + if let enumResultAsInt = enumResultAsInt { + return SK2SubscriptionOfferTypeMessage(rawValue: enumResultAsInt) + } + return nil + case 131: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as? Int) + if let enumResultAsInt = enumResultAsInt { + return SK2SubscriptionOfferPaymentModeMessage(rawValue: enumResultAsInt) + } + return nil + case 132: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as? Int) + if let enumResultAsInt = enumResultAsInt { + return SK2SubscriptionPeriodUnitMessage(rawValue: enumResultAsInt) + } + return nil + case 133: + return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?]) + case 134: + return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?]) + case 135: + return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?]) + case 136: + return SK2ProductMessage.fromList(self.readValue() as! [Any?]) + case 137: + return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class sk2_pigeonPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? SK2ProductTypeMessage { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? SK2SubscriptionOfferTypeMessage { + super.writeByte(130) + super.writeValue(value.rawValue) + } else if let value = value as? SK2SubscriptionOfferPaymentModeMessage { + super.writeByte(131) + super.writeValue(value.rawValue) + } else if let value = value as? SK2SubscriptionPeriodUnitMessage { + super.writeByte(132) + super.writeValue(value.rawValue) + } else if let value = value as? SK2SubscriptionOfferMessage { + super.writeByte(133) + super.writeValue(value.toList()) + } else if let value = value as? SK2SubscriptionPeriodMessage { + super.writeByte(134) + super.writeValue(value.toList()) + } else if let value = value as? SK2SubscriptionInfoMessage { + super.writeByte(135) + super.writeValue(value.toList()) + } else if let value = value as? SK2ProductMessage { + super.writeByte(136) + super.writeValue(value.toList()) + } else if let value = value as? SK2PriceLocaleMessage { + super.writeByte(137) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class sk2_pigeonPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return sk2_pigeonPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return sk2_pigeonPigeonCodecWriter(data: data) + } +} + +class sk2_pigeonPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = sk2_pigeonPigeonCodec(readerWriter: sk2_pigeonPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol InAppPurchase2API { + func canMakePayments() throws -> Bool + func products( + identifiers: [String], completion: @escaping (Result<[SK2ProductMessage], Error>) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class InAppPurchase2APISetup { + static var codec: FlutterStandardMessageCodec { sk2_pigeonPigeonCodec.shared } + /// Sets up an instance of `InAppPurchase2API` to handle messages through the `binaryMessenger`. + static func setUp( + binaryMessenger: FlutterBinaryMessenger, api: InAppPurchase2API?, + messageChannelSuffix: String = "" + ) { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let canMakePaymentsChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.canMakePayments\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + canMakePaymentsChannel.setMessageHandler { _, reply in + do { + let result = try api.canMakePayments() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + canMakePaymentsChannel.setMessageHandler(nil) + } + let productsChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.products\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + productsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let identifiersArg = args[0] as! [String] + api.products(identifiers: identifiersArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + productsChannel.setMessageHandler(nil) + } + } +} 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 c51ca4754912..caf8c62f6e80 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 @@ -22,9 +22,12 @@ F276940B2C47268700277144 /* ProductRequestHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F276940A2C47268700277144 /* ProductRequestHandlerTests.swift */; }; F27694112C49BF6F00277144 /* FIAPPaymentQueueDeleteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27694102C49BF6F00277144 /* FIAPPaymentQueueDeleteTests.swift */; }; F27694172C49DBCA00277144 /* FIATransactionCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27694162C49DBCA00277144 /* FIATransactionCacheTests.swift */; }; + F2858EE72C76A3B70063A092 /* InAppPurchaseStoreKit2PluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2858EE62C76A3B70063A092 /* InAppPurchaseStoreKit2PluginTests.swift */; }; + F2858EE82C76A4230063A092 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = F6E5D5F926131C4800C68BED /* Configuration.storekit */; }; + F2858EF02C7F98E70063A092 /* StoreKit2TranslatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2858EEF2C7F98E70063A092 /* StoreKit2TranslatorTests.swift */; }; F295AD3A2C1256DD0067C78A /* Stubs.m in Sources */ = {isa = PBXBuildFile; fileRef = F295AD392C1256DD0067C78A /* Stubs.m */; }; - F2D5272A2C583C4A00C137C7 /* TranslatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D527292C583C4A00C137C7 /* TranslatorTests.swift */; }; F2D5271A2C50627500C137C7 /* PaymentQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D527192C50627500C137C7 /* PaymentQueueTests.swift */; }; + F2D5272A2C583C4A00C137C7 /* TranslatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D527292C583C4A00C137C7 /* TranslatorTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -79,10 +82,12 @@ F276940A2C47268700277144 /* ProductRequestHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProductRequestHandlerTests.swift; path = ../../shared/RunnerTests/ProductRequestHandlerTests.swift; sourceTree = ""; }; F27694102C49BF6F00277144 /* FIAPPaymentQueueDeleteTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FIAPPaymentQueueDeleteTests.swift; path = ../../shared/RunnerTests/FIAPPaymentQueueDeleteTests.swift; sourceTree = ""; }; F27694162C49DBCA00277144 /* FIATransactionCacheTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FIATransactionCacheTests.swift; path = ../../shared/RunnerTests/FIATransactionCacheTests.swift; sourceTree = ""; }; + F2858EE62C76A3B70063A092 /* InAppPurchaseStoreKit2PluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseStoreKit2PluginTests.swift; sourceTree = ""; }; + F2858EEF2C7F98E70063A092 /* StoreKit2TranslatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2TranslatorTests.swift; sourceTree = ""; }; F295AD362C1251300067C78A /* Stubs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Stubs.h; path = ../../shared/RunnerTests/Stubs.h; sourceTree = ""; }; F295AD392C1256DD0067C78A /* Stubs.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Stubs.m; path = ../../shared/RunnerTests/Stubs.m; sourceTree = ""; }; - F2D527292C583C4A00C137C7 /* TranslatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TranslatorTests.swift; path = ../../shared/RunnerTests/TranslatorTests.swift; sourceTree = ""; }; F2D527192C50627500C137C7 /* PaymentQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PaymentQueueTests.swift; path = ../../shared/RunnerTests/PaymentQueueTests.swift; sourceTree = ""; }; + F2D527292C583C4A00C137C7 /* TranslatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TranslatorTests.swift; path = ../../shared/RunnerTests/TranslatorTests.swift; sourceTree = ""; }; F6E5D5F926131C4800C68BED /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; /* End PBXFileReference section */ @@ -196,6 +201,8 @@ F295AD362C1251300067C78A /* Stubs.h */, F22BF91B2BC9B40B00713878 /* SwiftStubs.swift */, F22BF91A2BC9B40B00713878 /* RunnerTests-Bridging-Header.h */, + F2858EE62C76A3B70063A092 /* InAppPurchaseStoreKit2PluginTests.swift */, + F2858EEF2C7F98E70063A092 /* StoreKit2TranslatorTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -304,6 +311,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + F2858EE82C76A4230063A092 /* Configuration.storekit in Resources */, 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, @@ -392,7 +400,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; 9AF65E7BDC9361CB3944EE9C /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -436,6 +444,8 @@ F2D5271A2C50627500C137C7 /* PaymentQueueTests.swift in Sources */, F24C45E22C409D42000C6C72 /* InAppPurchasePluginTests.swift in Sources */, F22BF91C2BC9B40B00713878 /* SwiftStubs.swift in Sources */, + F2858EE72C76A3B70063A092 /* InAppPurchaseStoreKit2PluginTests.swift in Sources */, + F2858EF02C7F98E70063A092 /* StoreKit2TranslatorTests.swift in Sources */, F276940B2C47268700277144 /* ProductRequestHandlerTests.swift in Sources */, F295AD3A2C1256DD0067C78A /* Stubs.m in Sources */, F2D5272A2C583C4A00C137C7 /* TranslatorTests.swift in Sources */, 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 58f3d4304fc6..d4484faac8d2 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 @@ -51,7 +51,10 @@ "_storefront" : "USA", "_storeKitErrors" : [ { - "current" : null, + "current" : { + "index" : 2, + "type" : "generic" + }, "enabled" : false, "name" : "Load Products" }, diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift new file mode 100644 index 000000000000..20a8bf8621b5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -0,0 +1,99 @@ +// 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 StoreKitTest +import XCTest + +@testable import in_app_purchase_storekit + +@available(iOS 15.0, *) +final class InAppPurchase2PluginTests: XCTestCase { + private var session: SKTestSession! + private var plugin: InAppPurchasePlugin! + + override func setUp() async throws { + try await super.setUp() + + self.session = try! SKTestSession(configurationFileNamed: "Configuration") + self.session.clearTransactions() + let receiptManagerStub = FIAPReceiptManagerStub() + plugin = InAppPurchasePluginStub(receiptManager: receiptManagerStub) { request in + DefaultRequestHandler(requestHandler: FIAPRequestHandler(request: request)) + } + } + + func testCanMakePayments() throws { + let result = try plugin.canMakePayments() + XCTAssertTrue(result) + } + + func testGetProducts() async throws { + let expectation = self.expectation(description: "products successfully fetched") + + var fetchedProductMsg: SK2ProductMessage? + plugin.products(identifiers: ["subscription_silver"]) { result in + switch result { + case .success(let productMessages): + fetchedProductMsg = productMessages.first + expectation.fulfill() + case .failure(let error): + // Handle the error + print("Failed to fetch products: \(error.localizedDescription)") + } + } + await fulfillment(of: [expectation], timeout: 5) + + let testProduct = try await Product.products(for: ["subscription_silver"]).first + + let testProductMsg = testProduct?.convertToPigeon + + XCTAssertNotNil(fetchedProductMsg) + XCTAssertEqual(testProductMsg, fetchedProductMsg) + } + + func testGetInvalidProducts() async throws { + let expectation = self.expectation(description: "products successfully fetched") + + var fetchedProductMsg: [SK2ProductMessage]? + plugin.products(identifiers: ["invalid_product"]) { result in + switch result { + case .success(let productMessages): + fetchedProductMsg = productMessages + expectation.fulfill() + case .failure(_): + XCTFail("Products should be successfully fetched") + } + } + await fulfillment(of: [expectation], timeout: 5) + + XCTAssert(fetchedProductMsg?.count == 0) + } + + //TODO(louisehsu): Add testing for lower versions. + @available(iOS 17.0, *) + func testGetProductsWithStoreKitError() async throws { + try await session.setSimulatedError( + .generic(.networkError(URLError(.badURL))), forAPI: .loadProducts) + + let expectation = self.expectation(description: "products request should fail") + + plugin.products(identifiers: ["subscription_silver"]) { result in + switch result { + case .success(_): + XCTFail("This `products` call should not succeed") + case .failure(let error): + expectation.fulfill() + print(error.localizedDescription) + XCTAssert( + error.localizedDescription + == "The operation couldn’t be completed. (in_app_purchase_storekit.PigeonError error 1.)" + ) + } + } + await fulfillment(of: [expectation], timeout: 5) + + // Reset test session + try await session.setSimulatedError(nil, forAPI: .loadProducts) + } +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/StoreKit2TranslatorTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/StoreKit2TranslatorTests.swift new file mode 100644 index 000000000000..44a64132dd8a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/StoreKit2TranslatorTests.swift @@ -0,0 +1,82 @@ +// 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 Foundation +import StoreKitTest +import XCTest + +@testable import in_app_purchase_storekit + +@available(iOS 15.0, macOS 12.0, *) +class StoreKit2TranslatorTests: XCTestCase { + var session: SKTestSession! + var plugin: InAppPurchasePlugin! + var product: Product! + + // This is transcribed from the Configuration.storekit file. + var productMessage: SK2ProductMessage = + SK2ProductMessage( + id: "subscription_silver", + displayName: "Subscription Silver", + description: "A lower level subscription.", + price: 4.99, + displayPrice: "$4.99", + type: SK2ProductTypeMessage.autoRenewable, + subscription: SK2SubscriptionInfoMessage( + promotionalOffers: [], + subscriptionGroupID: "D0FEE8D8", + subscriptionPeriod: SK2SubscriptionPeriodMessage( + value: 1, + 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() + plugin = InAppPurchasePluginStub(receiptManager: receiptManagerStub) { request in + DefaultRequestHandler(requestHandler: FIAPRequestHandler(request: request)) + } + product = try await Product.products(for: ["subscription_silver"]).first! + + } + + func testPigeonConversionForProduct() async throws { + XCTAssertNotNil(product) + let pigeonMessage = product.convertToPigeon + XCTAssertEqual(pigeonMessage, productMessage) + } + + func testPigeonConversionForSubscriptionInfo() async throws { + guard let subscription = product.subscription else { + XCTFail("SubscriptionInfo should not be nil") + return + } + let pigeonMessage = subscription.convertToPigeon + XCTAssertEqual(pigeonMessage, productMessage.subscription) + } + + func testPigeonConversionForProductType() async throws { + let type = product.type + let pigeonMessage = type.convertToPigeon + XCTAssertEqual(pigeonMessage, productMessage.type) + } + + func testPigeonConversionForSubscriptionPeriod() async throws { + guard let period = product.subscription?.subscriptionPeriod else { + XCTFail("SubscriptionPeriod should not be nil") + return + } + let pigeonMessage = period.convertToPigeon + XCTAssertEqual(pigeonMessage, productMessage.subscription?.subscriptionPeriod) + } + + func testPigeonConversionForPriceLocale() async throws { + let locale = product.priceFormatStyle.locale + let pigeonMessage = locale.convertToPigeon + XCTAssertEqual(pigeonMessage, productMessage.priceLocale) + } +} 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 713a8c909e69..4bbd863b8447 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 @@ -9,6 +9,7 @@ import 'package:flutter/services.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import '../in_app_purchase_storekit.dart'; +import '../store_kit_2_wrappers.dart'; import '../store_kit_wrappers.dart'; /// [IAPError.code] code for failed purchases. @@ -29,6 +30,9 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { @visibleForTesting InAppPurchaseStoreKitPlatform(); + /// Experimental flag for StoreKit2. + static bool _useStoreKit2 = false; + static late SKPaymentQueueWrapper _skPaymentQueueWrapper; static late _TransactionObserver _observer; @@ -65,7 +69,12 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { } @override - Future isAvailable() => SKPaymentQueueWrapper.canMakePayments(); + Future isAvailable() { + if (_useStoreKit2) { + return AppStore().canMakePayments(); + } + return SKPaymentQueueWrapper.canMakePayments(); + } @override Future buyNonConsumable({required PurchaseParam purchaseParam}) async { @@ -119,6 +128,38 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { @override Future queryProductDetails( Set identifiers) async { + if (_useStoreKit2) { + List products = []; + Set invalidProductIdentifiers; + PlatformException? exception; + try { + products = await SK2Product.products(identifiers.toList()); + // Storekit 2 no longer automatically returns a list of invalid identifiers, + // so get the difference between given identifiers and returned products + invalidProductIdentifiers = identifiers.difference( + products.map((SK2Product product) => product.id).toSet()); + } on PlatformException catch (e) { + exception = e; + invalidProductIdentifiers = identifiers; + } + List productDetails; + productDetails = products + .map((SK2Product productWrapper) => + AppStoreProduct2Details.fromSK2Product(productWrapper)) + .toList(); + final ProductDetailsResponse response = ProductDetailsResponse( + productDetails: productDetails, + notFoundIDs: invalidProductIdentifiers.toList(), + error: exception == null + ? null + : IAPError( + source: kIAPSource, + code: exception.code, + message: exception.message ?? '', + details: exception.details), + ); + return response; + } final SKRequestMaker requestMaker = SKRequestMaker(); SkProductResponseWrapper response; PlatformException? exception; @@ -166,6 +207,11 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { /// Use countryCode instead. @Deprecated('Use countryCode') Future getCountryCode() => countryCode(); + + /// Turns on StoreKit2. You cannot disable this after it is enabled. + void enableStoreKit2() { + _useStoreKit2 = true; + } } 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 new file mode 100644 index 000000000000..d45d16074ef3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart @@ -0,0 +1,416 @@ +// 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 (v22.0.0), 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 + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse( + {Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +enum SK2ProductTypeMessage { + /// A consumable in-app purchase. + consumable, + + /// A non-consumable in-app purchase. + nonConsumable, + + /// A non-renewing subscription. + nonRenewable, + + /// An auto-renewable subscription. + autoRenewable, +} + +enum SK2SubscriptionOfferTypeMessage { + introductory, + promotional, +} + +enum SK2SubscriptionOfferPaymentModeMessage { + payAsYouGo, + payUpFront, + freeTrial, +} + +enum SK2SubscriptionPeriodUnitMessage { + day, + week, + month, + year, +} + +class SK2SubscriptionOfferMessage { + SK2SubscriptionOfferMessage({ + this.id, + required this.price, + required this.type, + required this.period, + required this.periodCount, + required this.paymentMode, + }); + + String? id; + + double price; + + SK2SubscriptionOfferTypeMessage type; + + SK2SubscriptionPeriodMessage period; + + int periodCount; + + SK2SubscriptionOfferPaymentModeMessage paymentMode; + + Object encode() { + return [ + id, + price, + type, + period, + periodCount, + paymentMode, + ]; + } + + static SK2SubscriptionOfferMessage decode(Object result) { + result as List; + return SK2SubscriptionOfferMessage( + id: result[0] as String?, + price: result[1]! as double, + type: result[2]! as SK2SubscriptionOfferTypeMessage, + period: result[3]! as SK2SubscriptionPeriodMessage, + periodCount: result[4]! as int, + paymentMode: result[5]! as SK2SubscriptionOfferPaymentModeMessage, + ); + } +} + +class SK2SubscriptionPeriodMessage { + SK2SubscriptionPeriodMessage({ + required this.value, + required this.unit, + }); + + /// The number of units that the period represents. + int value; + + /// The unit of time that this period represents. + SK2SubscriptionPeriodUnitMessage unit; + + Object encode() { + return [ + value, + unit, + ]; + } + + static SK2SubscriptionPeriodMessage decode(Object result) { + result as List; + return SK2SubscriptionPeriodMessage( + value: result[0]! as int, + unit: result[1]! as SK2SubscriptionPeriodUnitMessage, + ); + } +} + +class SK2SubscriptionInfoMessage { + SK2SubscriptionInfoMessage({ + required this.promotionalOffers, + required this.subscriptionGroupID, + required this.subscriptionPeriod, + }); + + /// An array of all the promotional offers configured for this subscription. + /// This should be List but pigeon doesnt support + /// null-safe generics. https://github.com/flutter/flutter/issues/97848 + List promotionalOffers; + + /// The group identifier for this subscription. + String subscriptionGroupID; + + /// The duration that this subscription lasts before auto-renewing. + SK2SubscriptionPeriodMessage subscriptionPeriod; + + Object encode() { + return [ + promotionalOffers, + subscriptionGroupID, + subscriptionPeriod, + ]; + } + + static SK2SubscriptionInfoMessage decode(Object result) { + result as List; + return SK2SubscriptionInfoMessage( + promotionalOffers: + (result[0] as List?)!.cast(), + subscriptionGroupID: result[1]! as String, + subscriptionPeriod: result[2]! as SK2SubscriptionPeriodMessage, + ); + } +} + +/// A Pigeon message class representing a Product +/// https://developer.apple.com/documentation/storekit/product +class SK2ProductMessage { + SK2ProductMessage({ + required this.id, + required this.displayName, + required this.description, + required this.price, + required this.displayPrice, + required this.type, + this.subscription, + required this.priceLocale, + }); + + /// The unique product identifier. + String id; + + /// The localized display name of the product, if it exists. + String displayName; + + /// The localized description of the product. + String description; + + /// The localized string representation of the product price, suitable for display. + double price; + + /// The localized price of the product as a string. + String displayPrice; + + /// The types of in-app purchases. + SK2ProductTypeMessage type; + + /// The subscription information for an auto-renewable subscription. + SK2SubscriptionInfoMessage? subscription; + + /// The currency and locale information for this product + SK2PriceLocaleMessage priceLocale; + + Object encode() { + return [ + id, + displayName, + description, + price, + displayPrice, + type, + subscription, + priceLocale, + ]; + } + + static SK2ProductMessage decode(Object result) { + result as List; + return SK2ProductMessage( + id: result[0]! as String, + displayName: result[1]! as String, + description: result[2]! as String, + price: result[3]! as double, + displayPrice: result[4]! as String, + type: result[5]! as SK2ProductTypeMessage, + subscription: result[6] as SK2SubscriptionInfoMessage?, + priceLocale: result[7]! as SK2PriceLocaleMessage, + ); + } +} + +class SK2PriceLocaleMessage { + SK2PriceLocaleMessage({ + required this.currencyCode, + required this.currencySymbol, + }); + + String currencyCode; + + String currencySymbol; + + Object encode() { + return [ + currencyCode, + currencySymbol, + ]; + } + + static SK2PriceLocaleMessage decode(Object result) { + result as List; + return SK2PriceLocaleMessage( + currencyCode: result[0]! as String, + currencySymbol: result[1]! as String, + ); + } +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is SK2ProductTypeMessage) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferTypeMessage) { + buffer.putUint8(130); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferPaymentModeMessage) { + buffer.putUint8(131); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionPeriodUnitMessage) { + buffer.putUint8(132); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferMessage) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is SK2SubscriptionPeriodMessage) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is SK2SubscriptionInfoMessage) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else if (value is SK2ProductMessage) { + buffer.putUint8(136); + writeValue(buffer, value.encode()); + } else if (value is SK2PriceLocaleMessage) { + buffer.putUint8(137); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : SK2ProductTypeMessage.values[value]; + case 130: + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2SubscriptionOfferTypeMessage.values[value]; + case 131: + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2SubscriptionOfferPaymentModeMessage.values[value]; + case 132: + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2SubscriptionPeriodUnitMessage.values[value]; + case 133: + return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); + case 134: + return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); + case 135: + return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); + case 136: + return SK2ProductMessage.decode(readValue(buffer)!); + case 137: + return SK2PriceLocaleMessage.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class InAppPurchase2API { + /// Constructor for [InAppPurchase2API]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + InAppPurchase2API( + {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future canMakePayments() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.canMakePayments$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) 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> products(List identifiers) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.products$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([identifiers]) 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 List?)! + .cast(); + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_appstore_wrapper.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_appstore_wrapper.dart new file mode 100644 index 000000000000..a57ad5611266 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_appstore_wrapper.dart @@ -0,0 +1,18 @@ +// 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 '../../store_kit_2_wrappers.dart'; + +InAppPurchase2API _hostApi = InAppPurchase2API(); + +/// Wrapper for StoreKit2's AppStore +/// (https://developer.apple.com/documentation/storekit/appstore) +final class AppStore { + /// Dart wrapper for StoreKit2's canMakePayments() + /// Returns a bool that indicates whether the person can make purchases. + /// (https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments) + Future canMakePayments() { + return _hostApi.canMakePayments(); + } +} 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 new file mode 100644 index 000000000000..9decfa433c9b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart @@ -0,0 +1,352 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import '../../store_kit_2_wrappers.dart'; + +InAppPurchase2API _hostApi = InAppPurchase2API(); + +/// A wrapper around StoreKit2's ProductType +/// https://developer.apple.com/documentation/storekit/product/producttype +/// The types of in-app purchases. +enum SK2ProductType { + /// A consumable in-app purchase. + consumable, + + /// A non-consumable in-app purchase. + nonConsumable, + + /// A non-renewing subscription. + nonRenewable, + + /// An auto-renewable subscription. + autoRenewable; +} + +extension on SK2ProductTypeMessage { + /// Convert the equivalent pigeon class of [SK2ProductTypeMessage] into an instance of [SK2ProductType] + SK2ProductType convertFromPigeon() { + switch (this) { + case SK2ProductTypeMessage.autoRenewable: + return SK2ProductType.autoRenewable; + case SK2ProductTypeMessage.consumable: + return SK2ProductType.consumable; + case SK2ProductTypeMessage.nonConsumable: + return SK2ProductType.nonConsumable; + case SK2ProductTypeMessage.nonRenewable: + return SK2ProductType.nonRenewable; + } + } +} + +extension on SK2ProductType { + SK2ProductTypeMessage convertToPigeon() { + switch (this) { + case SK2ProductType.autoRenewable: + return SK2ProductTypeMessage.autoRenewable; + case SK2ProductType.consumable: + return SK2ProductTypeMessage.consumable; + case SK2ProductType.nonConsumable: + return SK2ProductTypeMessage.nonConsumable; + case SK2ProductType.nonRenewable: + return SK2ProductTypeMessage.nonRenewable; + } + } +} + +/// A wrapper around StoreKit2's SubscriptionOfferType +/// https://developer.apple.com/documentation/appstoreserverapi/offertype/ +/// The subscription offer types. +enum SK2SubscriptionOfferType { + /// An introductory offer. + introductory, + + /// A A promotional offer. + promotional +} + +extension on SK2SubscriptionOfferTypeMessage { + SK2SubscriptionOfferType convertFromPigeon() { + switch (this) { + case SK2SubscriptionOfferTypeMessage.introductory: + return SK2SubscriptionOfferType.introductory; + case SK2SubscriptionOfferTypeMessage.promotional: + return SK2SubscriptionOfferType.promotional; + } + } +} + +/// A wrapper around StoreKit2's SubscriptionOffer +/// https://developer.apple.com/documentation/storekit/product/subscriptionoffer +/// Information about a subscription offer on a product. +class SK2SubscriptionOffer { + /// Creates a new [SK2SubscriptionOffer] + SK2SubscriptionOffer({ + this.id, + required this.price, + required this.type, + required this.period, + required this.periodCount, + required this.paymentMode, + }); + + /// The subscription offer identifier. + final String? id; + + /// The decimal representation of the discounted price of the subscription offer. + final double price; + + /// The type of subscription offer, either introductory or promotional. + final SK2SubscriptionOfferType type; + + /// The subscription period for the subscription offer. + final SK2SubscriptionPeriod period; + + /// The number of periods that the subscription offer renews for. + final int periodCount; + + /// The payment modes for subscription offers that apply to a transaction. + final SK2SubscriptionOfferPaymentMode paymentMode; +} + +extension on SK2SubscriptionOfferMessage { + SK2SubscriptionOffer convertFromPigeon() { + return SK2SubscriptionOffer( + id: id, + price: price, + type: type.convertFromPigeon(), + period: period.convertFromPigeon(), + periodCount: periodCount, + paymentMode: paymentMode.convertFromPigeon()); + } +} + +/// A wrapper around StoreKit2's SubscriptionInfo +/// https://developer.apple.com/documentation/storekit/product/subscriptioninfo +/// Information about an auto-renewable subscription, +/// such as its status, period, subscription group, and subscription offer details. +class SK2SubscriptionInfo { + /// Creates a new instance of [SK2SubscriptionInfo] + const SK2SubscriptionInfo({ + required this.subscriptionGroupID, + required this.promotionalOffers, + required this.subscriptionPeriod, + }); + + /// An array of all the promotional offers configured for this subscription. + final List promotionalOffers; + + /// The group identifier for this subscription. + final String subscriptionGroupID; + + /// The duration that this subscription lasts before auto-renewing. + final SK2SubscriptionPeriod subscriptionPeriod; +} + +extension on SK2SubscriptionInfoMessage { + SK2SubscriptionInfo convertFromPigeon() { + return SK2SubscriptionInfo( + subscriptionGroupID: subscriptionGroupID, + // Note that promotionalOffers should NOT be nullable, but is only declared + // so because of pigeon cannot handle non null lists. + // There should be NO NULLS. + promotionalOffers: promotionalOffers + .whereType() + .map((SK2SubscriptionOfferMessage offer) => + offer.convertFromPigeon()) + .toList(), + subscriptionPeriod: subscriptionPeriod.convertFromPigeon()); + } +} + +/// A wrapper around StoreKit2's SubscriptionPeriod +/// https://developer.apple.com/documentation/storekit/product/subscriptionperiod +/// Values that represent the duration of time between subscription renewals. +class SK2SubscriptionPeriod { + /// Creates a new instance of [SK2SubscriptionPeriod] + const SK2SubscriptionPeriod({required this.value, required this.unit}); + + /// The number of units that the period represents. + final int value; + + /// The unit of time that this period represents. + final SK2SubscriptionPeriodUnit unit; +} + +extension on SK2SubscriptionPeriodMessage { + SK2SubscriptionPeriod convertFromPigeon() { + return SK2SubscriptionPeriod(value: value, unit: unit.convertFromPigeon()); + } +} + +/// A wrapper around StoreKit2's SubscriptionPeriodUnit +/// https://developer.apple.com/documentation/storekit/product/subscriptionperiod/3749576-unit +/// The increment of time for the subscription period. +enum SK2SubscriptionPeriodUnit { + /// A subscription period unit of a day. + day, + + /// A subscription period unit of a week. + week, + + /// A subscription period unit of a month. + month, + + /// A subscription period unit of a year. + year +} + +extension on SK2SubscriptionPeriodUnitMessage { + SK2SubscriptionPeriodUnit convertFromPigeon() { + switch (this) { + case SK2SubscriptionPeriodUnitMessage.day: + return SK2SubscriptionPeriodUnit.day; + case SK2SubscriptionPeriodUnitMessage.week: + return SK2SubscriptionPeriodUnit.week; + case SK2SubscriptionPeriodUnitMessage.month: + return SK2SubscriptionPeriodUnit.month; + case SK2SubscriptionPeriodUnitMessage.year: + return SK2SubscriptionPeriodUnit.year; + } + } +} + +/// A wrapper around StoreKit2's [PaymentMode](https://developer.apple.com/documentation/storekit/product/subscriptionoffer/paymentmode) +/// The payment modes for subscription offers that apply to a transaction. +enum SK2SubscriptionOfferPaymentMode { + /// A payment mode of a product discount that applies over a single billing period or multiple billing periods. + payAsYouGo, + + /// A payment mode of a product discount that applies the discount up front. + payUpFront, + + /// A payment mode of a product discount that indicates a free trial offer. + freeTrial; +} + +extension on SK2SubscriptionOfferPaymentModeMessage { + SK2SubscriptionOfferPaymentMode convertFromPigeon() { + switch (this) { + case SK2SubscriptionOfferPaymentModeMessage.payAsYouGo: + return SK2SubscriptionOfferPaymentMode.payAsYouGo; + case SK2SubscriptionOfferPaymentModeMessage.payUpFront: + return SK2SubscriptionOfferPaymentMode.payUpFront; + case SK2SubscriptionOfferPaymentModeMessage.freeTrial: + return SK2SubscriptionOfferPaymentMode.freeTrial; + } + } +} + +/// A wrapper around StoreKit2's [Locale](https://developer.apple.com/documentation/foundation/locale) +/// The payment modes for subscription offers that apply to a transaction. +class SK2PriceLocale { + /// Creates a new instance of [SK2PriceLocale] + SK2PriceLocale({required this.currencyCode, required this.currencySymbol}); + + /// The currency code this format style uses. + final String currencyCode; + + /// The currency symbol this format style uses. + final String currencySymbol; + + /// Convert this instance of [SK2PriceLocale] to [SK2PriceLocaleMessage] + SK2PriceLocaleMessage convertToPigeon() { + return SK2PriceLocaleMessage( + currencyCode: currencyCode, currencySymbol: currencySymbol); + } +} + +extension on SK2PriceLocaleMessage { + SK2PriceLocale convertFromPigeon() { + return SK2PriceLocale( + currencyCode: currencyCode, currencySymbol: currencySymbol); + } +} + +/// A wrapper around StoreKit2's [Product](https://developer.apple.com/documentation/storekit/product). +/// The Product type represents the in-app purchases that you configure in +/// App Store Connect and make available for purchase within your app. +class SK2Product { + /// Creates a new [SKStorefrontWrapper] with the provided information. + SK2Product({ + required this.id, + required this.displayName, + required this.displayPrice, + required this.description, + required this.price, + required this.type, + required this.priceLocale, + this.subscription, + }); + + /// The unique product identifier. + final String id; + + /// The localized display name of the product, if it exists. + final String displayName; + + /// The localized description of the product. + final String description; + + /// The localized string representation of the product price, suitable for display. + final double price; + + /// The localized price of the product as a string. + final String displayPrice; + + /// The types of in-app purchases. + final SK2ProductType type; + + /// The subscription information for an auto-renewable subscription. + final SK2SubscriptionInfo? subscription; + + /// The locale and currency information for this product. + final SK2PriceLocale priceLocale; + + /// https://developer.apple.com/documentation/storekit/product/3851116-products + /// Given a list of identifiers, return a list of products + /// If any of the identifiers are invalid or can't be found, they are excluded + /// from the returned list. + static Future> products(List identifiers) async { + final List productsMsg = + await _hostApi.products(identifiers); + if (productsMsg.isEmpty && identifiers.isNotEmpty) { + throw PlatformException( + code: 'storekit_no_response', + message: 'StoreKit: Failed to get response from platform.', + ); + } + + return productsMsg + .whereType() + .map((SK2ProductMessage product) => product.convertFromPigeon()) + .toList(); + } + + /// Converts this instance of [SK2Product] to it's pigeon representation [SK2ProductMessage] + SK2ProductMessage convertToPigeon() { + return SK2ProductMessage( + id: id, + displayName: displayName, + description: description, + price: price, + displayPrice: displayPrice, + type: type.convertToPigeon(), + priceLocale: priceLocale.convertToPigeon()); + } +} + +extension on SK2ProductMessage { + SK2Product convertFromPigeon() { + return SK2Product( + id: id, + displayName: displayName, + displayPrice: displayPrice, + price: price, + description: description, + type: type.convertFromPigeon(), + subscription: subscription?.convertFromPigeon(), + priceLocale: priceLocale.convertFromPigeon()); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_product_details.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_product_details.dart index 47bcf616fa40..f0ab7257b375 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_product_details.dart @@ -5,6 +5,7 @@ import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import '../../store_kit_wrappers.dart'; +import '../store_kit_2_wrappers/sk2_product_wrapper.dart'; /// The class represents the information of a product as registered in the Apple /// AppStore. @@ -42,3 +43,38 @@ class AppStoreProductDetails extends ProductDetails { /// this [AppStoreProductDetails] object. final SKProductWrapper skProduct; } + +/// The class represents the information of a StoreKit2 product as registered in the Apple +/// AppStore. +class AppStoreProduct2Details extends ProductDetails { + /// Creates a new AppStore specific product details object with the provided + /// details. + AppStoreProduct2Details({ + required super.id, + required super.title, + required super.description, + required super.price, + required super.rawPrice, + required super.currencyCode, + required this.sk2Product, + required super.currencySymbol, + }); + + /// Generate a [AppStoreProductDetails] object based on an iOS [SKProductWrapper] object. + factory AppStoreProduct2Details.fromSK2Product(SK2Product product) { + return AppStoreProduct2Details( + id: product.id, + title: product.displayName, + description: product.description, + price: product.priceLocale.currencySymbol + product.price.toString(), + rawPrice: product.price, + currencyCode: product.priceLocale.currencyCode, + currencySymbol: product.priceLocale.currencySymbol, + sk2Product: product, + ); + } + + /// Points back to the [SKProductWrapper] object that was used to generate + /// this [AppStoreProductDetails] object. + final SK2Product sk2Product; +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/store_kit_2_wrappers.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/store_kit_2_wrappers.dart new file mode 100644 index 000000000000..746cc3f378a1 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/store_kit_2_wrappers.dart @@ -0,0 +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. + +export 'src/sk2_pigeon.g.dart'; +export 'src/store_kit_2_wrappers/sk2_appstore_wrapper.dart'; +export 'src/store_kit_2_wrappers/sk2_product_wrapper.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 new file mode 100644 index 000000000000..941dd6dbb272 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart @@ -0,0 +1,141 @@ +// 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:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/sk2_pigeon.g.dart', + dartTestOut: 'test/sk2_test_api.g.dart', + swiftOut: 'darwin/Classes/StoreKit2/sk2_pigeon.g.swift', + copyrightHeader: 'pigeons/copyright.txt', +)) +enum SK2ProductTypeMessage { + /// A consumable in-app purchase. + consumable, + + /// A non-consumable in-app purchase. + nonConsumable, + + /// A non-renewing subscription. + nonRenewable, + + /// An auto-renewable subscription. + autoRenewable +} + +enum SK2SubscriptionOfferTypeMessage { introductory, promotional } + +enum SK2SubscriptionOfferPaymentModeMessage { + payAsYouGo, + payUpFront, + freeTrial, +} + +class SK2SubscriptionOfferMessage { + const SK2SubscriptionOfferMessage({ + this.id, + required this.price, + required this.type, + required this.period, + required this.periodCount, + required this.paymentMode, + }); + final String? id; + final double price; + final SK2SubscriptionOfferTypeMessage type; + final SK2SubscriptionPeriodMessage period; + final int periodCount; + final SK2SubscriptionOfferPaymentModeMessage paymentMode; +} + +enum SK2SubscriptionPeriodUnitMessage { day, week, month, year } + +class SK2SubscriptionPeriodMessage { + const SK2SubscriptionPeriodMessage({required this.value, required this.unit}); + + /// The number of units that the period represents. + final int value; + + /// The unit of time that this period represents. + final SK2SubscriptionPeriodUnitMessage unit; +} + +class SK2SubscriptionInfoMessage { + const SK2SubscriptionInfoMessage({ + required this.subscriptionGroupID, + required this.promotionalOffers, + required this.subscriptionPeriod, + }); + + /// An array of all the promotional offers configured for this subscription. + /// This should be List but pigeon doesnt support + /// null-safe generics. https://github.com/flutter/flutter/issues/97848 + final List promotionalOffers; + + /// The group identifier for this subscription. + final String subscriptionGroupID; + + /// The duration that this subscription lasts before auto-renewing. + final SK2SubscriptionPeriodMessage subscriptionPeriod; +} + +/// A Pigeon message class representing a Product +/// https://developer.apple.com/documentation/storekit/product +class SK2ProductMessage { + const SK2ProductMessage( + {required this.id, + required this.displayName, + required this.displayPrice, + required this.description, + required this.price, + required this.type, + this.subscription, + required this.priceLocale}); + + /// The unique product identifier. + final String id; + + /// The localized display name of the product, if it exists. + final String displayName; + + /// The localized description of the product. + final String description; + + /// The localized string representation of the product price, suitable for display. + final double price; + + /// The localized price of the product as a string. + final String displayPrice; + + /// The types of in-app purchases. + final SK2ProductTypeMessage type; + + /// The subscription information for an auto-renewable subscription. + final SK2SubscriptionInfoMessage? subscription; + + /// The currency and locale information for this product + final SK2PriceLocaleMessage priceLocale; +} + +class SK2PriceLocaleMessage { + SK2PriceLocaleMessage({ + required this.currencyCode, + required this.currencySymbol, + }); + + final String currencyCode; + final String currencySymbol; +} + +@HostApi(dartHostTestHandler: 'TestInAppPurchase2Api') +abstract class InAppPurchase2API { + // https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments + // SK1 canMakePayments + bool canMakePayments(); + + // https://developer.apple.com/documentation/storekit/product/3851116-products + // SK1 startProductRequest + @async + List products(List identifiers); +} 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 2dad81edd9e1..ad1fac7ad970 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.3.18 +version: 0.3.18+1 environment: sdk: ^3.3.0 @@ -31,7 +31,7 @@ dev_dependencies: flutter_test: sdk: flutter json_serializable: ^6.0.0 - pigeon: ^16.0.4 + pigeon: ^22.0.0 test: ^1.16.0 topics: 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 973d9df5fe73..6a5f6a53b644 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 @@ -6,8 +6,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart'; import 'package:in_app_purchase_storekit/src/messages.g.dart'; +import 'package:in_app_purchase_storekit/src/sk2_pigeon.g.dart'; +import 'package:in_app_purchase_storekit/src/store_kit_2_wrappers/sk2_product_wrapper.dart'; import 'package:in_app_purchase_storekit/store_kit_wrappers.dart'; +import '../sk2_test_api.g.dart'; import '../store_kit_wrappers/sk_test_stub_objects.dart'; import '../test_api.g.dart'; @@ -277,3 +280,51 @@ class FakeStoreKitPlatform implements TestInAppPurchaseApi { queueIsActive = false; } } + +class FakeStoreKit2Platform implements TestInAppPurchase2Api { + late Set validProductIDs; + late Map validProducts; + PlatformException? queryProductException; + + void reset() { + validProductIDs = {'123', '456'}; + validProducts = {}; + for (final String validID in validProductIDs) { + final SK2Product product = SK2Product( + id: validID, + displayName: 'test_product', + displayPrice: '0.99', + description: 'description', + price: 0.99, + type: SK2ProductType.consumable, + priceLocale: + SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$')); + validProducts[validID] = product; + } + } + + @override + bool canMakePayments() { + return true; + } + + @override + Future> products(List identifiers) { + if (queryProductException != null) { + throw queryProductException!; + } + final List productIDS = identifiers; + final List products = []; + for (final String? productID in productIDS) { + if (validProductIDs.contains(productID)) { + products.add(validProducts[productID]!); + } + } + final List result = []; + for (final SK2Product p in products) { + result.add(p.convertToPigeon()); + } + + return Future>.value(result); + } +} 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 new file mode 100644 index 000000000000..afc08cf71b9e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart @@ -0,0 +1,73 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; +import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart'; + +import 'fakes/fake_storekit_platform.dart'; +import 'sk2_test_api.g.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakeStoreKit2Platform fakeStoreKit2Platform = FakeStoreKit2Platform(); + late InAppPurchaseStoreKitPlatform iapStoreKitPlatform; + + setUpAll(() { + TestInAppPurchase2Api.setUp(fakeStoreKit2Platform); + }); + + setUp(() { + InAppPurchaseStoreKitPlatform.registerPlatform(); + iapStoreKitPlatform = + InAppPurchasePlatform.instance as InAppPurchaseStoreKitPlatform; + iapStoreKitPlatform.enableStoreKit2(); + fakeStoreKit2Platform.reset(); + }); + + tearDown(() => fakeStoreKit2Platform.reset()); + + group('isAvailable', () { + test('true', () async { + expect(await iapStoreKitPlatform.isAvailable(), isTrue); + }); + }); + + group('query product list', () { + test('should get product list and correct invalid identifiers', () async { + final InAppPurchaseStoreKitPlatform connection = + InAppPurchaseStoreKitPlatform(); + final ProductDetailsResponse response = + await connection.queryProductDetails({'123', '456', '789'}); + final List products = response.productDetails; + expect(products.first.id, '123'); + expect(products[1].id, '456'); + expect(response.notFoundIDs, ['789']); + expect(response.error, isNull); + expect(response.productDetails.first.currencySymbol, r'$'); + expect(response.productDetails[1].currencySymbol, r'$'); + }); + test( + 'if query products throws error, should get error object in the response', + () async { + fakeStoreKit2Platform.queryProductException = PlatformException( + code: 'error_code', + message: 'error_message', + details: {'info': 'error_info'}); + final InAppPurchaseStoreKitPlatform connection = + InAppPurchaseStoreKitPlatform(); + final ProductDetailsResponse response = + await connection.queryProductDetails({'123', '456', '789'}); + expect(response.productDetails, []); + expect(response.notFoundIDs, ['123', '456', '789']); + expect(response.error, isNotNull); + expect(response.error!.source, kIAPSource); + expect(response.error!.code, 'error_code'); + expect(response.error!.message, 'error_message'); + expect(response.error!.details, {'info': 'error_info'}); + }); + }); +} 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 new file mode 100644 index 000000000000..21ad07890668 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart @@ -0,0 +1,169 @@ +// 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 (v22.0.0), 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 +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:in_app_purchase_storekit/src/sk2_pigeon.g.dart'; + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is SK2ProductTypeMessage) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferTypeMessage) { + buffer.putUint8(130); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferPaymentModeMessage) { + buffer.putUint8(131); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionPeriodUnitMessage) { + buffer.putUint8(132); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferMessage) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is SK2SubscriptionPeriodMessage) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is SK2SubscriptionInfoMessage) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else if (value is SK2ProductMessage) { + buffer.putUint8(136); + writeValue(buffer, value.encode()); + } else if (value is SK2PriceLocaleMessage) { + buffer.putUint8(137); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : SK2ProductTypeMessage.values[value]; + case 130: + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2SubscriptionOfferTypeMessage.values[value]; + case 131: + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2SubscriptionOfferPaymentModeMessage.values[value]; + case 132: + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2SubscriptionPeriodUnitMessage.values[value]; + case 133: + return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); + case 134: + return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); + case 135: + return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); + case 136: + return SK2ProductMessage.decode(readValue(buffer)!); + case 137: + return SK2PriceLocaleMessage.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +abstract class TestInAppPurchase2Api { + static TestDefaultBinaryMessengerBinding? get _testBinaryMessengerBinding => + TestDefaultBinaryMessengerBinding.instance; + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + bool canMakePayments(); + + Future> products(List identifiers); + + static void setUp( + TestInAppPurchase2Api? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.canMakePayments$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + try { + final bool output = api.canMakePayments(); + 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< + Object?>( + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.products$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.products was null.'); + final List args = (message as List?)!; + final List? arg_identifiers = + (args[0] as List?)?.cast(); + assert(arg_identifiers != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.products was null, expected non-null List.'); + try { + final List output = + await api.products(arg_identifiers!); + return [output]; + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +}