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 f072c092770..1fb5e1bb97d 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+2 + +* Adds support for StoreKit2's `purchase` and `transactions` + ## 0.3.18+1 * Adds support for StoreKit2's `canMakePayments` and `products` 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 a591739eb69..43d500a8e3d 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 @@ -27,6 +27,21 @@ public class InAppPurchasePlugin: NSObject, FlutterPlugin, InAppPurchaseAPI { // This property is optional, as it requires self to exist to be initialized. public var paymentQueueHandler: FLTPaymentQueueHandlerProtocol? + // This should be an Task, but Task is on available >= iOS 13 + private var _updateListenerTask: Any? + + @available(iOS 13.0, *) + var updateListenerTask: Task<(), Never> { + return self._updateListenerTask as! Task<(), Never> + } + + @available(iOS 13.0, *) + func setListenerTaskAsTask(task: Task<(), Never>) { + self._updateListenerTask = task + } + + var transactionCallbackAPI: InAppPurchase2CallbackAPI? = nil + public static func register(with registrar: FlutterPluginRegistrar) { #if os(iOS) let messenger = registrar.messenger() @@ -93,6 +108,7 @@ public class InAppPurchasePlugin: NSObject, FlutterPlugin, InAppPurchaseAPI { let messenger = registrar.messenger #endif setupTransactionObserverChannelIfNeeded(withMessenger: messenger) + self.transactionCallbackAPI = InAppPurchase2CallbackAPI(binaryMessenger: messenger) } // MARK: - Pigeon Functions 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 index 7ab0405ae67..cd8e96b5d13 100644 --- 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 @@ -4,16 +4,17 @@ @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 + /// 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 + /// 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 ) { @@ -34,4 +35,146 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } } + + /// Gets the appropriate product, then calls purchase on it. + /// https://developer.apple.com/documentation/storekit/product/3791971-purchase + func purchase( + id: String, options: SK2ProductPurchaseOptionsMessage?, + completion: @escaping (Result) -> Void + ) { + Task { @MainActor in + do { + guard let product = try await Product.products(for: [id]).first else { + let error = PigeonError( + code: "storekit2_failed_to_fetch_product", + message: "Storekit has failed to fetch this product.", + details: "Product ID : \(id)") + return completion(.failure(error)) + } + + let result = try await product.purchase(options: []) + + switch result { + case .success(let verification): + switch verification { + case .verified(let transaction): + self.sendTransactionUpdate(transaction: transaction) + completion(.success(result.convertToPigeon())) + case .unverified(_, let error): + completion(.failure(error)) + } + case .pending: + completion( + .failure( + PigeonError( + code: "storekit2_purchase_pending", + message: + "This transaction is still pending and but may complete in the future. If it completes, it will be delivered via `purchaseStream`", + details: "Product ID : \(id)"))) + case .userCancelled: + completion( + .failure( + PigeonError( + code: "storekit2_purchase_cancelled", + message: "This transaction has been cancelled by the user.", + details: "Product ID : \(id)"))) + @unknown default: + fatalError("An unknown StoreKit PurchaseResult has been encountered.") + } + } catch { + completion(.failure(error)) + } + } + } + + /// Wrapper method around StoreKit2's transactions() method + /// https://developer.apple.com/documentation/storekit/product/3851116-products + func transactions( + completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void + ) { + Task { + @MainActor in + do { + let transactionsMsgs = await rawTransactions().map { + $0.convertToPigeon() + } + completion(.success(transactionsMsgs)) + } + } + } + + /// Wrapper method around StoreKit2's finish() method https://developer.apple.com/documentation/storekit/transaction/3749694-finish + func finish(id: Int64, completion: @escaping (Result) -> Void) { + Task { + let transaction = try await fetchTransaction(by: UInt64(id)) + if let transaction = transaction { + await transaction.finish() + } + } + } + + /// This Task listens to Transation.updates as shown here + /// https://developer.apple.com/documentation/storekit/transaction/3851206-updates + /// This function should be called as soon as the app starts to avoid missing any Transactions done outside of the app. + func startListeningToTransactions() throws { + self.setListenerTaskAsTask( + task: Task { [weak self] in + for await verificationResult in Transaction.updates { + switch verificationResult { + case .verified(let transaction): + self?.sendTransactionUpdate(transaction: transaction) + case .unverified: + break + } + } + }) + } + + /// Stop subscribing to Transaction.updates + func stopListeningToTransactions() throws { + updateListenerTask.cancel() + } + + /// Sends an transaction back to Dart. Access these transactions with `purchaseStream` + func sendTransactionUpdate(transaction: Transaction) { + let transactionMessage = transaction.convertToPigeon() + transactionCallbackAPI?.onTransactionsUpdated(newTransaction: transactionMessage) { result in + switch result { + case .success: break + case .failure(let error): + print("Failed to send transaction updates: \(error)") + } + } + } + + // MARK: - Convenience Functions + + /// Helper function that fetches and unwraps all verified transactions + private func rawTransactions() async -> [Transaction] { + var transactions: [Transaction] = [] + for await verificationResult in Transaction.all { + switch verificationResult { + case .verified(let transaction): + transactions.append(transaction) + case .unverified: + break + } + } + return transactions + } + + /// Helper function to fetch specific transaction + private func fetchTransaction(by id: UInt64) async throws -> Transaction? { + for await result in Transaction.all { + switch result { + case .verified(let transaction): + if transaction.id == id { + return transaction + } + case .unverified: + continue + } + } + return nil + } } 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 index aed0059733e..b04caa25ae6 100644 --- 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 @@ -167,3 +167,38 @@ extension SK2PriceLocaleMessage: Equatable { return lhs.currencyCode == rhs.currencyCode && lhs.currencySymbol == rhs.currencySymbol } } + +@available(iOS 15.0, macOS 12.0, *) +extension Product.PurchaseResult { + func convertToPigeon() -> SK2ProductPurchaseResultMessage { + switch self { + case .success(_): + return SK2ProductPurchaseResultMessage.success + case .userCancelled: + return SK2ProductPurchaseResultMessage.userCancelled + case .pending: + return SK2ProductPurchaseResultMessage.pending + @unknown default: + fatalError() + } + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension Transaction { + func convertToPigeon(restoring: Bool = false) -> SK2TransactionMessage { + + let dateFromatter: DateFormatter = DateFormatter() + dateFromatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + return SK2TransactionMessage( + id: Int64(id), + originalId: Int64(originalID), + productId: productID, + purchaseDate: dateFromatter.string(from: purchaseDate), + purchasedQuantity: Int64(purchasedQuantity), + appAccountToken: appAccountToken?.uuidString, + restoring: restoring + ) + } +} 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 index 119fb288f36..6a327812444 100644 --- 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 @@ -58,6 +58,12 @@ private func wrapError(_ error: Any) -> [Any?] { ] } +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError( + code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", + details: "") +} + private func isNullish(_ value: Any?) -> Bool { return value is NSNull || value == nil } @@ -96,6 +102,12 @@ enum SK2SubscriptionPeriodUnitMessage: Int { case year = 3 } +enum SK2ProductPurchaseResultMessage: Int { + case success = 0 + case userCancelled = 1 + case pending = 2 +} + /// Generated class from Pigeon that represents data sent in messages. struct SK2SubscriptionOfferMessage { var id: String? = nil @@ -275,6 +287,111 @@ struct SK2PriceLocaleMessage { } } +/// Generated class from Pigeon that represents data sent in messages. +struct SK2ProductPurchaseOptionsMessage { + var appAccountToken: String? = nil + var quantity: Int64? = nil + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2ProductPurchaseOptionsMessage? { + let appAccountToken: String? = nilOrValue(pigeonVar_list[0]) + let quantity: Int64? = + isNullish(pigeonVar_list[1]) + ? nil + : (pigeonVar_list[1] is Int64? + ? pigeonVar_list[1] as! Int64? : Int64(pigeonVar_list[1] as! Int32)) + + return SK2ProductPurchaseOptionsMessage( + appAccountToken: appAccountToken, + quantity: quantity + ) + } + func toList() -> [Any?] { + return [ + appAccountToken, + quantity, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SK2TransactionMessage { + var id: Int64 + var originalId: Int64 + var productId: String + var purchaseDate: String + var purchasedQuantity: Int64 + var appAccountToken: String? = nil + var restoring: Bool + var error: SK2ErrorMessage? = nil + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2TransactionMessage? { + let id = + pigeonVar_list[0] is Int64 ? pigeonVar_list[0] as! Int64 : Int64(pigeonVar_list[0] as! Int32) + let originalId = + pigeonVar_list[1] is Int64 ? pigeonVar_list[1] as! Int64 : Int64(pigeonVar_list[1] as! Int32) + let productId = pigeonVar_list[2] as! String + let purchaseDate = pigeonVar_list[3] as! String + let purchasedQuantity = + pigeonVar_list[4] is Int64 ? pigeonVar_list[4] as! Int64 : Int64(pigeonVar_list[4] as! Int32) + let appAccountToken: String? = nilOrValue(pigeonVar_list[5]) + let restoring = pigeonVar_list[6] as! Bool + let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[7]) + + return SK2TransactionMessage( + id: id, + originalId: originalId, + productId: productId, + purchaseDate: purchaseDate, + purchasedQuantity: purchasedQuantity, + appAccountToken: appAccountToken, + restoring: restoring, + error: error + ) + } + func toList() -> [Any?] { + return [ + id, + originalId, + productId, + purchaseDate, + purchasedQuantity, + appAccountToken, + restoring, + error, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SK2ErrorMessage { + var code: Int64 + var domain: String + var userInfo: [String?: Any?]? = nil + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SK2ErrorMessage? { + let code = + pigeonVar_list[0] is Int64 ? pigeonVar_list[0] as! Int64 : Int64(pigeonVar_list[0] as! Int32) + let domain = pigeonVar_list[1] as! String + let userInfo: [String?: Any?]? = nilOrValue(pigeonVar_list[2]) + + return SK2ErrorMessage( + code: code, + domain: domain, + userInfo: userInfo + ) + } + func toList() -> [Any?] { + return [ + code, + domain, + userInfo, + ] + } +} + private class sk2_pigeonPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -303,15 +420,27 @@ private class sk2_pigeonPigeonCodecReader: FlutterStandardReader { } return nil case 133: - return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?]) + let enumResultAsInt: Int? = nilOrValue(self.readValue() as? Int) + if let enumResultAsInt = enumResultAsInt { + return SK2ProductPurchaseResultMessage(rawValue: enumResultAsInt) + } + return nil case 134: - return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?]) case 135: - return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?]) case 136: - return SK2ProductMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?]) case 137: + return SK2ProductMessage.fromList(self.readValue() as! [Any?]) + case 138: return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?]) + case 139: + return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?]) + case 140: + return SK2TransactionMessage.fromList(self.readValue() as! [Any?]) + case 141: + return SK2ErrorMessage.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -332,20 +461,32 @@ private class sk2_pigeonPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? SK2SubscriptionPeriodUnitMessage { super.writeByte(132) super.writeValue(value.rawValue) - } else if let value = value as? SK2SubscriptionOfferMessage { + } else if let value = value as? SK2ProductPurchaseResultMessage { super.writeByte(133) + super.writeValue(value.rawValue) + } else if let value = value as? SK2SubscriptionOfferMessage { + super.writeByte(134) super.writeValue(value.toList()) } else if let value = value as? SK2SubscriptionPeriodMessage { - super.writeByte(134) + super.writeByte(135) super.writeValue(value.toList()) } else if let value = value as? SK2SubscriptionInfoMessage { - super.writeByte(135) + super.writeByte(136) super.writeValue(value.toList()) } else if let value = value as? SK2ProductMessage { - super.writeByte(136) + super.writeByte(137) super.writeValue(value.toList()) } else if let value = value as? SK2PriceLocaleMessage { - super.writeByte(137) + super.writeByte(138) + super.writeValue(value.toList()) + } else if let value = value as? SK2ProductPurchaseOptionsMessage { + super.writeByte(139) + super.writeValue(value.toList()) + } else if let value = value as? SK2TransactionMessage { + super.writeByte(140) + super.writeValue(value.toList()) + } else if let value = value as? SK2ErrorMessage { + super.writeByte(141) super.writeValue(value.toList()) } else { super.writeValue(value) @@ -372,6 +513,13 @@ protocol InAppPurchase2API { func canMakePayments() throws -> Bool func products( identifiers: [String], completion: @escaping (Result<[SK2ProductMessage], Error>) -> Void) + func purchase( + id: String, options: SK2ProductPurchaseOptionsMessage?, + completion: @escaping (Result) -> Void) + func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void) + func finish(id: Int64, completion: @escaping (Result) -> Void) + func startListeningToTransactions() throws + func stopListeningToTransactions() throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -419,5 +567,135 @@ class InAppPurchase2APISetup { } else { productsChannel.setMessageHandler(nil) } + let purchaseChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.purchase\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + purchaseChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let idArg = args[0] as! String + let optionsArg: SK2ProductPurchaseOptionsMessage? = nilOrValue(args[1]) + api.purchase(id: idArg, options: optionsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + purchaseChannel.setMessageHandler(nil) + } + let transactionsChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + transactionsChannel.setMessageHandler { _, reply in + api.transactions { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + transactionsChannel.setMessageHandler(nil) + } + let finishChannel = FlutterBasicMessageChannel( + name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + finishChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let idArg = args[0] is Int64 ? args[0] as! Int64 : Int64(args[0] as! Int32) + api.finish(id: idArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + finishChannel.setMessageHandler(nil) + } + let startListeningToTransactionsChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.startListeningToTransactions\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + startListeningToTransactionsChannel.setMessageHandler { _, reply in + do { + try api.startListeningToTransactions() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + startListeningToTransactionsChannel.setMessageHandler(nil) + } + let stopListeningToTransactionsChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.stopListeningToTransactions\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + stopListeningToTransactionsChannel.setMessageHandler { _, reply in + do { + try api.stopListeningToTransactions() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + stopListeningToTransactionsChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol InAppPurchase2CallbackAPIProtocol { + func onTransactionsUpdated( + newTransaction newTransactionArg: SK2TransactionMessage, + completion: @escaping (Result) -> Void) +} +class InAppPurchase2CallbackAPI: InAppPurchase2CallbackAPIProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: sk2_pigeonPigeonCodec { + return sk2_pigeonPigeonCodec.shared + } + func onTransactionsUpdated( + newTransaction newTransactionArg: SK2TransactionMessage, + completion: @escaping (Result) -> Void + ) { + let channelName: String = + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel( + name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([newTransactionArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(Void())) + } + } } } 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 caf8c62f6e8..9d01ee6865f 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 @@ -18,14 +18,14 @@ C4667AA10A6BC70CE9A5007C /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = AB9CD9DD098BDAB3D5053EE5 /* libPods-RunnerTests.a */; }; E680BD031412EB2D02C9190B /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 21CE6E615CF661FC0E18FB0A /* libPods-Runner.a */; }; F22BF91C2BC9B40B00713878 /* SwiftStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22BF91B2BC9B40B00713878 /* SwiftStubs.swift */; }; + F22FD7A22CB080AE0006F28F /* StoreKit2TranslatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22FD7A12CB080AE0006F28F /* StoreKit2TranslatorTests.swift */; }; F24C45E22C409D42000C6C72 /* InAppPurchasePluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F24C45E12C409D41000C6C72 /* InAppPurchasePluginTests.swift */; }; 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 */; }; + F2D127492CB4A76D005FA2E5 /* InAppPurchaseStoreKit2PluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D127482CB4A76D005FA2E5 /* InAppPurchaseStoreKit2PluginTests.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 */ @@ -78,14 +78,14 @@ CC9E5595B2B9B9B90632DA75 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F22BF91A2BC9B40B00713878 /* RunnerTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RunnerTests-Bridging-Header.h"; sourceTree = ""; }; F22BF91B2BC9B40B00713878 /* SwiftStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftStubs.swift; sourceTree = ""; }; + F22FD7A12CB080AE0006F28F /* StoreKit2TranslatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StoreKit2TranslatorTests.swift; path = ../shared/RunnerTests/StoreKit2TranslatorTests.swift; sourceTree = SOURCE_ROOT; }; F24C45E12C409D41000C6C72 /* InAppPurchasePluginTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = InAppPurchasePluginTests.swift; path = ../../shared/RunnerTests/InAppPurchasePluginTests.swift; sourceTree = ""; }; 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 = ""; }; + F2D127482CB4A76D005FA2E5 /* InAppPurchaseStoreKit2PluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InAppPurchaseStoreKit2PluginTests.swift; path = ../shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift; sourceTree = SOURCE_ROOT; }; 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 = ""; }; @@ -191,6 +191,8 @@ A59001A521E69658004A3E5E /* RunnerTests */ = { isa = PBXGroup; children = ( + F2D127482CB4A76D005FA2E5 /* InAppPurchaseStoreKit2PluginTests.swift */, + F22FD7A12CB080AE0006F28F /* StoreKit2TranslatorTests.swift */, F2D527292C583C4A00C137C7 /* TranslatorTests.swift */, F2D527192C50627500C137C7 /* PaymentQueueTests.swift */, F27694162C49DBCA00277144 /* FIATransactionCacheTests.swift */, @@ -201,8 +203,6 @@ F295AD362C1251300067C78A /* Stubs.h */, F22BF91B2BC9B40B00713878 /* SwiftStubs.swift */, F22BF91A2BC9B40B00713878 /* RunnerTests-Bridging-Header.h */, - F2858EE62C76A3B70063A092 /* InAppPurchaseStoreKit2PluginTests.swift */, - F2858EEF2C7F98E70063A092 /* StoreKit2TranslatorTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -444,12 +444,12 @@ 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 */, + F22FD7A22CB080AE0006F28F /* StoreKit2TranslatorTests.swift in Sources */, F276940B2C47268700277144 /* ProductRequestHandlerTests.swift in Sources */, F295AD3A2C1256DD0067C78A /* Stubs.m in Sources */, F2D5272A2C583C4A00C137C7 /* TranslatorTests.swift in Sources */, F27694172C49DBCA00277144 /* FIATransactionCacheTests.swift in Sources */, + F2D127492CB4A76D005FA2E5 /* InAppPurchaseStoreKit2PluginTests.swift in Sources */, F27694112C49BF6F00277144 /* FIAPPaymentQueueDeleteTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 477131feb69..9c3ad5d54dd 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -39,6 +39,7 @@ skipped = "NO"> - - 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 new file mode 100644 index 00000000000..c2fcfbdfedf --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -0,0 +1,229 @@ +// 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, macOS 12.0, *) + +final class InAppPurchase2PluginTests: XCTestCase { + private var session: SKTestSession! + private var plugin: InAppPurchasePlugin! + + override func setUp() async throws { + try await super.setUp() + + session = try! SKTestSession(configurationFileNamed: "Configuration") + session.resetToDefaultState() + session.clearTransactions() + session.disableDialogs = true + + plugin = InAppPurchasePluginStub(receiptManager: FIAPReceiptManagerStub()) { request in + DefaultRequestHandler(requestHandler: FIAPRequestHandler(request: request)) + } + try plugin.startListeningToTransactions() + } + + override func tearDown() async throws { + self.session.clearTransactions() + session.disableDialogs = false + } + + 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): + 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 testGetDiscountedProducts() 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): 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, macOS 14.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() + XCTAssert( + error.localizedDescription + == "The operation couldn’t be completed. (in_app_purchase_storekit.PigeonError error 1.)" + ) + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testSuccessfulPurchase() async throws { + let expectation = self.expectation(description: "Purchase request should succeed") + plugin.purchase(id: "consumable", options: nil) { result in + switch result { + case .success(let purchaseResult): + expectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + @available(iOS 17.0, macOS 14.0, *) + func testFailedNetworkErrorPurchase() async throws { + try await session.setSimulatedError( + .generic(.networkError(URLError(.badURL))), forAPI: .loadProducts) + let expectation = self.expectation(description: "products request should fail") + plugin.purchase(id: "consumable", options: nil) { result in + switch result { + case .success(_): + XCTFail("Purchase should NOT suceed.") + case .failure(let error): + XCTAssertEqual( + error.localizedDescription, + "The operation couldn’t be completed. (NSURLErrorDomain error -1009.)") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + @available(iOS 17.0, macOS 14.0, *) + func testFailedProductUnavilablePurchase() async throws { + try await session.setSimulatedError( + .purchase(.productUnavailable), forAPI: .purchase) + let expectation = self.expectation(description: "Purchase request should succeed") + plugin.purchase(id: "consumable", options: nil) { result in + switch result { + case .success(_): + XCTFail("Purchase should NOT suceed.") + case .failure(let error): + XCTAssertEqual(error.localizedDescription, "Item Unavailable") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testInvalidProductPurchase() async throws { + let expectation = self.expectation(description: "products request should fail") + plugin.purchase(id: "invalid_product", options: nil) { result in + switch result { + case .success(_): + XCTFail("Purchase should NOT suceed.") + case .failure(let error): + let pigeonError = error as! PigeonError + + XCTAssertEqual(pigeonError.code, "storekit2_failed_to_fetch_product") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testPurchaseUpgradeConsumableSuccess() async throws { + let expectation = self.expectation(description: "Purchase request should succeed") + plugin.purchase(id: "subscription_discounted", options: nil) { result in + switch result { + case .success(let purchaseResult): + expectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testDiscountedSubscriptionSuccess() async throws { + let expectation = self.expectation(description: "Purchase request should succeed") + plugin.purchase(id: "subscription_discounted", options: nil) { result in + switch result { + case .success(let purchaseResult): + expectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(error)") + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testDiscountedProductSuccess() async throws { + let expectation = self.expectation(description: "Purchase request should succeed") + plugin.purchase(id: "consumable_discounted", options: nil) { result in + switch result { + case .success(let purchaseResult): + expectation.fulfill() + case .failure(let error): + XCTFail("Purchase should NOT fail. Failed with \(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 new file mode 100644 index 00000000000..6fbd1f8444f --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/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, *) +final class StoreKit2TranslatorTests: XCTestCase { + private var session: SKTestSession! + private var plugin: InAppPurchasePlugin! + private 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 4bbd863b844..f930e57a8e7 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 @@ -33,16 +33,26 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { /// Experimental flag for StoreKit2. static bool _useStoreKit2 = false; + /// StoreKit1 static late SKPaymentQueueWrapper _skPaymentQueueWrapper; - static late _TransactionObserver _observer; + static late _TransactionObserver _sk1transactionObserver; + + /// StoreKit2 + static late SK2TransactionObserverWrapper _sk2transactionObserver; @override - Stream> get purchaseStream => - _observer.purchaseUpdatedController.stream; + Stream> get purchaseStream => _useStoreKit2 + ? _sk2transactionObserver.transactionsCreatedController.stream + : _sk1transactionObserver.purchaseUpdatedController.stream; /// Callback handler for transaction status changes. @visibleForTesting - static SKTransactionObserverWrapper get observer => _observer; + static SKTransactionObserverWrapper get observer => _sk1transactionObserver; + + /// Callback handler for transaction status changes for StoreKit2 transactions + @visibleForTesting + static SK2TransactionObserverWrapper get sk2transactionObserver => + _sk2transactionObserver; /// Registers this class as the default instance of [InAppPurchasePlatform]. static void registerPlatform() { @@ -57,15 +67,26 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { _skPaymentQueueWrapper = SKPaymentQueueWrapper(); - // Create a purchaseUpdatedController and notify the native side when to - // start of stop sending updates. - final StreamController> updateController = - StreamController>.broadcast( - onListen: () => _skPaymentQueueWrapper.startObservingTransactionQueue(), - onCancel: () => _skPaymentQueueWrapper.stopObservingTransactionQueue(), - ); - _observer = _TransactionObserver(updateController); - _skPaymentQueueWrapper.setTransactionObserver(observer); + if (_useStoreKit2) { + final StreamController> updateController2 = + StreamController>.broadcast( + onListen: () => SK2Transaction.startListeningToTransactions(), + onCancel: () => SK2Transaction.stopListeningToTransactions(), + ); + _sk2transactionObserver = SK2TransactionObserverWrapper( + transactionsCreatedController: updateController2); + InAppPurchase2CallbackAPI.setUp(_sk2transactionObserver); + } else { + // Create a purchaseUpdatedController and notify the native side when to + // start of stop sending updates. + final StreamController> updateController = + StreamController>.broadcast( + onListen: () => _skPaymentQueueWrapper.startObservingTransactionQueue(), + onCancel: () => _skPaymentQueueWrapper.stopObservingTransactionQueue(), + ); + _sk1transactionObserver = _TransactionObserver(updateController); + _skPaymentQueueWrapper.setTransactionObserver(observer); + } } @override @@ -78,6 +99,17 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { @override Future buyNonConsumable({required PurchaseParam purchaseParam}) async { + if (_useStoreKit2) { + final SK2ProductPurchaseOptions options = SK2ProductPurchaseOptions( + quantity: purchaseParam is AppStorePurchaseParam + ? purchaseParam.quantity + : 1, + appAccountToken: purchaseParam.applicationUserName); + await SK2Product.purchase(purchaseParam.productDetails.id, + options: options); + + return true; + } await _skPaymentQueueWrapper.addPayment(SKPaymentWrapper( productIdentifier: purchaseParam.productDetails.id, quantity: @@ -102,10 +134,14 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { @override Future completePurchase(PurchaseDetails purchase) { assert( - purchase is AppStorePurchaseDetails, + purchase is AppStorePurchaseDetails || purchase is SK2PurchaseDetails, 'On iOS, the `purchase` should always be of type `AppStorePurchaseDetails`.', ); + if (_useStoreKit2) { + return SK2Transaction.finish(int.parse(purchase.purchaseID!)); + } + return _skPaymentQueueWrapper.finishTransaction( (purchase as AppStorePurchaseDetails).skPaymentTransaction, ); @@ -113,11 +149,12 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { @override Future restorePurchases({String? applicationUserName}) async { - return _observer + return _sk1transactionObserver .restoreTransactions( queue: _skPaymentQueueWrapper, applicationUserName: applicationUserName) - .whenComplete(() => _observer.cleanUpRestoredTransactions()); + .whenComplete( + () => _sk1transactionObserver.cleanUpRestoredTransactions()); } /// Query the product detail list. 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 d45d16074ef..1dcc2cad771 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 @@ -61,6 +61,12 @@ enum SK2SubscriptionPeriodUnitMessage { year, } +enum SK2ProductPurchaseResultMessage { + success, + userCancelled, + pending, +} + class SK2SubscriptionOfferMessage { SK2SubscriptionOfferMessage({ this.id, @@ -264,6 +270,119 @@ class SK2PriceLocaleMessage { } } +class SK2ProductPurchaseOptionsMessage { + SK2ProductPurchaseOptionsMessage({ + this.appAccountToken, + this.quantity = 1, + }); + + String? appAccountToken; + + int? quantity; + + Object encode() { + return [ + appAccountToken, + quantity, + ]; + } + + static SK2ProductPurchaseOptionsMessage decode(Object result) { + result as List; + return SK2ProductPurchaseOptionsMessage( + appAccountToken: result[0] as String?, + quantity: result[1] as int?, + ); + } +} + +class SK2TransactionMessage { + SK2TransactionMessage({ + required this.id, + required this.originalId, + required this.productId, + required this.purchaseDate, + this.purchasedQuantity = 1, + this.appAccountToken, + this.restoring = false, + this.error, + }); + + int id; + + int originalId; + + String productId; + + String purchaseDate; + + int purchasedQuantity; + + String? appAccountToken; + + bool restoring; + + SK2ErrorMessage? error; + + Object encode() { + return [ + id, + originalId, + productId, + purchaseDate, + purchasedQuantity, + appAccountToken, + restoring, + error, + ]; + } + + static SK2TransactionMessage decode(Object result) { + result as List; + return SK2TransactionMessage( + id: result[0]! as int, + originalId: result[1]! as int, + productId: result[2]! as String, + purchaseDate: result[3]! as String, + purchasedQuantity: result[4]! as int, + appAccountToken: result[5] as String?, + restoring: result[6]! as bool, + error: result[7] as SK2ErrorMessage?, + ); + } +} + +class SK2ErrorMessage { + SK2ErrorMessage({ + required this.code, + required this.domain, + this.userInfo, + }); + + int code; + + String domain; + + Map? userInfo; + + Object encode() { + return [ + code, + domain, + userInfo, + ]; + } + + static SK2ErrorMessage decode(Object result) { + result as List; + return SK2ErrorMessage( + code: result[0]! as int, + domain: result[1]! as String, + userInfo: (result[2] as Map?)?.cast(), + ); + } +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -283,20 +402,32 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is SK2SubscriptionPeriodUnitMessage) { buffer.putUint8(132); writeValue(buffer, value.index); - } else if (value is SK2SubscriptionOfferMessage) { + } else if (value is SK2ProductPurchaseResultMessage) { buffer.putUint8(133); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferMessage) { + buffer.putUint8(134); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionPeriodMessage) { - buffer.putUint8(134); + buffer.putUint8(135); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionInfoMessage) { - buffer.putUint8(135); + buffer.putUint8(136); writeValue(buffer, value.encode()); } else if (value is SK2ProductMessage) { - buffer.putUint8(136); + buffer.putUint8(137); writeValue(buffer, value.encode()); } else if (value is SK2PriceLocaleMessage) { - buffer.putUint8(137); + buffer.putUint8(138); + writeValue(buffer, value.encode()); + } else if (value is SK2ProductPurchaseOptionsMessage) { + buffer.putUint8(139); + writeValue(buffer, value.encode()); + } else if (value is SK2TransactionMessage) { + buffer.putUint8(140); + writeValue(buffer, value.encode()); + } else if (value is SK2ErrorMessage) { + buffer.putUint8(141); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -325,15 +456,26 @@ class _PigeonCodec extends StandardMessageCodec { ? null : SK2SubscriptionPeriodUnitMessage.values[value]; case 133: - return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2ProductPurchaseResultMessage.values[value]; case 134: - return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); case 135: - return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); + return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); case 136: - return SK2ProductMessage.decode(readValue(buffer)!); + return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); case 137: + return SK2ProductMessage.decode(readValue(buffer)!); + case 138: return SK2PriceLocaleMessage.decode(readValue(buffer)!); + case 139: + return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); + case 140: + return SK2TransactionMessage.decode(readValue(buffer)!); + case 141: + return SK2ErrorMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -413,4 +555,181 @@ class InAppPurchase2API { .cast(); } } + + Future purchase(String id, + {SK2ProductPurchaseOptionsMessage? options}) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.purchase$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([id, options]) 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 SK2ProductPurchaseResultMessage?)!; + } + } + + Future> transactions() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions$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 List?)! + .cast(); + } + } + + Future finish(int id) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([id]) 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 { + return; + } + } + + Future startListeningToTransactions() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.startListeningToTransactions$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 { + return; + } + } + + Future stopListeningToTransactions() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.stopListeningToTransactions$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 { + return; + } + } +} + +abstract class InAppPurchase2CallbackAPI { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void onTransactionsUpdated(SK2TransactionMessage newTransaction); + + static void setUp( + InAppPurchase2CallbackAPI? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null.'); + final List args = (message as List?)!; + final SK2TransactionMessage? arg_newTransaction = + (args[0] as SK2TransactionMessage?); + assert(arg_newTransaction != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated was null, expected non-null SK2TransactionMessage.'); + try { + api.onTransactionsUpdated(arg_newTransaction!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } } 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 9decfa433c9..820ea211d7b 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 @@ -264,6 +264,51 @@ extension on SK2PriceLocaleMessage { } } +/// Wrapper around [PurchaseResult] +/// https://developer.apple.com/documentation/storekit/product/purchaseresult +enum SK2ProductPurchaseResult { + /// The purchase succeeded and results in a transaction. + success, + + /// The user canceled the purchase. + userCancelled, + + /// The purchase is pending, and requires action from the customer. + pending +} + +/// Wrapper around [PurchaseOption] +/// https://developer.apple.com/documentation/storekit/product/purchaseoption +class SK2ProductPurchaseOptions { + /// Creates a new instance of [SK2ProductPurchaseOptions] + SK2ProductPurchaseOptions({this.appAccountToken, this.quantity}); + + /// Sets a UUID to associate the purchase with an account in your system. + final String? appAccountToken; + + /// Indicates the quantity of items the customer is purchasing. + final int? quantity; + + /// Convert to pigeon representation [SK2ProductPurchaseOptionsMessage] + SK2ProductPurchaseOptionsMessage convertToPigeon() { + return SK2ProductPurchaseOptionsMessage( + appAccountToken: appAccountToken, quantity: quantity); + } +} + +extension on SK2ProductPurchaseResultMessage { + SK2ProductPurchaseResult convertFromPigeon() { + switch (this) { + case SK2ProductPurchaseResultMessage.success: + return SK2ProductPurchaseResult.success; + case SK2ProductPurchaseResultMessage.userCancelled: + return SK2ProductPurchaseResult.userCancelled; + case SK2ProductPurchaseResultMessage.pending: + return SK2ProductPurchaseResult.pending; + } + } +} + /// 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. @@ -324,6 +369,20 @@ class SK2Product { .toList(); } + /// Wrapper for StoreKit's [Product.purchase] + /// https://developer.apple.com/documentation/storekit/product/3791971-purchase + /// Initiates a purchase for the product with the App Store and displays the confirmation sheet. + static Future purchase(String id, + {SK2ProductPurchaseOptions? options}) async { + SK2ProductPurchaseResultMessage result; + if (options != null) { + result = await _hostApi.purchase(id, options: options.convertToPigeon()); + } else { + result = await _hostApi.purchase(id); + } + return result.convertFromPigeon(); + } + /// 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/store_kit_2_wrappers/sk2_transaction_wrapper.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart new file mode 100644 index 00000000000..c6ec3bf0d5a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart @@ -0,0 +1,136 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../in_app_purchase_storekit.dart'; +import '../../store_kit_wrappers.dart'; +import '../sk2_pigeon.g.dart'; + +InAppPurchase2API _hostApi = InAppPurchase2API(); + +/// Dart wrapper around StoreKit2's [Transaction](https://developer.apple.com/documentation/storekit/transaction) +/// Note that in StoreKit2, a Transaction encompasses the data contained by +/// SKPayment and SKTransaction in StoreKit1 +class SK2Transaction { + /// Creates a new instance of [SK2Transaction] + SK2Transaction( + {required this.id, + required this.originalId, + required this.productId, + required this.purchaseDate, + this.quantity = 1, + required this.appAccountToken, + this.subscriptionGroupID, + this.price, + this.error}); + + /// The unique identifier for the transaction. + final String id; + + /// The original transaction identifier of a purchase. + /// The original transaction identifier, originalID, is identical to id except + /// when the user restores a purchase or renews a transaction. + final String originalId; + + /// The product identifier of the in-app purchase. + final String productId; + + /// The date that the App Store charged the user’s account for a purchased or + /// restored product, or for a subscription purchase or renewal after a lapse. + final String purchaseDate; + + /// The number of consumable products purchased. + final int quantity; + + /// A UUID that associates the transaction with a user on your own service. + final String? appAccountToken; + + /// The identifier of the subscription group that the subscription belongs to. + final String? subscriptionGroupID; + + /// The price of the in-app purchase that the system records in the transaction. + final double? price; + + /// Any error returned from StoreKit + final SKError? error; + + /// Wrapper around [Transaction.finish] + /// https://developer.apple.com/documentation/storekit/transaction/3749694-finish + /// Indicates to the App Store that the app delivered the purchased content + /// or enabled the service to finish the transaction. + static Future finish(int id) async { + await _hostApi.finish(id); + } + + /// A wrapper around [Transaction.all] + /// https://developer.apple.com/documentation/storekit/transaction/3851203-all + /// A sequence that emits all the customer’s transactions for your app. + static Future> transactions() async { + final List msgs = await _hostApi.transactions(); + final List transactions = msgs + .map((SK2TransactionMessage? e) => e?.convertFromPigeon()) + .cast() + .toList(); + return transactions; + } + + /// Start listening to transactions. + /// Call this as soon as you can your app to avoid missing transactions. + static void startListeningToTransactions() { + _hostApi.startListeningToTransactions(); + } + + /// Stop listening to transactions. + static void stopListeningToTransactions() { + _hostApi.stopListeningToTransactions(); + } +} + +extension on SK2TransactionMessage { + SK2Transaction convertFromPigeon() { + return SK2Transaction( + id: id.toString(), + originalId: originalId.toString(), + productId: productId, + purchaseDate: purchaseDate, + appAccountToken: appAccountToken); + } + + PurchaseDetails convertToDetails() { + return SK2PurchaseDetails( + productID: productId, + // in SK2, as per Apple + // https://developer.apple.com/documentation/foundation/nsbundle/1407276-appstorereceipturl + // receipt isn’t necessary with SK2 as a Transaction can only be returned + // from validated purchases. + verificationData: PurchaseVerificationData( + localVerificationData: '', serverVerificationData: '', source: ''), + transactionDate: purchaseDate, + // Note that with SK2, any transactions that *can* be returned will + // require to be finished, and are already purchased. + // So set this as purchased for all transactions initially. + // Any failed transaction will simply not be returned. + status: restoring ? PurchaseStatus.restored : PurchaseStatus.purchased, + purchaseID: id.toString(), + ); + } +} + +/// An observer that listens to all transactions created +class SK2TransactionObserverWrapper implements InAppPurchase2CallbackAPI { + /// Creates a new instance of [SK2TransactionObserverWrapper] + SK2TransactionObserverWrapper({required this.transactionsCreatedController}); + + /// The transactions stream to listen to + final StreamController> transactionsCreatedController; + + @override + void onTransactionsUpdated(SK2TransactionMessage newTransaction) { + transactionsCreatedController + .add([newTransaction.convertToDetails()]); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart index 21a1e11116b..335ff6ad264 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart @@ -77,3 +77,18 @@ class AppStorePurchaseDetails extends PurchaseDetails { @override bool get pendingCompletePurchase => _pendingCompletePurchase; } + +/// The class represents the information of a purchase made with the Apple +/// AppStore, when using Storekit2 +class SK2PurchaseDetails extends PurchaseDetails { + /// Creates new instance of [SK2PurchaseDetails] + SK2PurchaseDetails( + {required super.productID, + required super.purchaseID, + required super.verificationData, + required super.transactionDate, + required super.status}); + + @override + bool get pendingCompletePurchase => status == PurchaseStatus.purchased; +} 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 index 746cc3f378a..05482180b1a 100644 --- 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 @@ -5,3 +5,4 @@ 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'; +export 'src/store_kit_2_wrappers/sk2_transaction_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 index 941dd6dbb27..b276524a587 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 @@ -128,14 +128,72 @@ class SK2PriceLocaleMessage { final String currencySymbol; } +class SK2ProductPurchaseOptionsMessage { + SK2ProductPurchaseOptionsMessage({ + this.appAccountToken, + this.quantity = 1, + }); + final String? appAccountToken; + final int? quantity; +} + +class SK2TransactionMessage { + SK2TransactionMessage( + {required this.id, + required this.originalId, + required this.productId, + required this.purchaseDate, + this.purchasedQuantity = 1, + this.appAccountToken, + this.error, + this.restoring = false}); + final int id; + final int originalId; + final String productId; + final String purchaseDate; + final int purchasedQuantity; + final String? appAccountToken; + final bool restoring; + final SK2ErrorMessage? error; +} + +class SK2ErrorMessage { + const SK2ErrorMessage( + {required this.code, required this.domain, required this.userInfo}); + + final int code; + final String domain; + final Map? userInfo; +} + +enum SK2ProductPurchaseResultMessage { success, userCancelled, pending } + @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); + + // https://developer.apple.com/documentation/storekit/product/3791971-purchase + @async + SK2ProductPurchaseResultMessage purchase(String id, + {SK2ProductPurchaseOptionsMessage? options}); + + @async + List transactions(); + + @async + void finish(int id); + + void startListeningToTransactions(); + + void stopListeningToTransactions(); +} + +@FlutterApi() +abstract class InAppPurchase2CallbackAPI { + void onTransactionsUpdated(SK2TransactionMessage newTransaction); } 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 ad1fac7ad97..fe60dcda583 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+1 +version: 0.3.18+2 environment: sdk: ^3.3.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 6a5f6a53b64..98e96e26d67 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 @@ -8,6 +8,7 @@ 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/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart'; import 'package:in_app_purchase_storekit/store_kit_wrappers.dart'; import '../sk2_test_api.g.dart'; @@ -284,7 +285,13 @@ class FakeStoreKitPlatform implements TestInAppPurchaseApi { class FakeStoreKit2Platform implements TestInAppPurchase2Api { late Set validProductIDs; late Map validProducts; + late List transactionList; + late bool testTransactionFail; + late int testTransactionCancel; + late List finishedTransactions; + PlatformException? queryProductException; + bool isListenerRegistered = false; void reset() { validProductIDs = {'123', '456'}; @@ -327,4 +334,50 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { return Future>.value(result); } + + @override + Future purchase(String id, + {SK2ProductPurchaseOptionsMessage? options}) { + final SK2TransactionMessage transaction = createPendingTransaction(id); + + InAppPurchaseStoreKitPlatform.sk2transactionObserver + .onTransactionsUpdated(transaction); + return Future.value( + SK2ProductPurchaseResultMessage.success); + } + + @override + Future finish(int id) { + return Future.value(); + } + + @override + Future> transactions() { + return Future>.value([ + SK2TransactionMessage( + id: 123, + originalId: 123, + productId: 'product_id', + purchaseDate: '12-12') + ]); + } + + @override + void startListeningToTransactions() { + isListenerRegistered = true; + } + + @override + void stopListeningToTransactions() { + isListenerRegistered = false; + } +} + +SK2TransactionMessage createPendingTransaction(String id, {int quantity = 1}) { + return SK2TransactionMessage( + id: 1, + originalId: 2, + productId: id, + purchaseDate: 'purchaseDate', + appAccountToken: 'appAccountToken'); } 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 afc08cf71b9..02c80b9d9be 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 @@ -2,15 +2,27 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + 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 'package:in_app_purchase_storekit/store_kit_2_wrappers.dart'; import 'fakes/fake_storekit_platform.dart'; import 'sk2_test_api.g.dart'; void main() { + final SK2Product dummyProductWrapper = SK2Product( + id: '2', + displayName: 'name', + displayPrice: '0.99', + description: 'desc', + price: 0.99, + type: SK2ProductType.consumable, + priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$')); + TestWidgetsFlutterBinding.ensureInitialized(); final FakeStoreKit2Platform fakeStoreKit2Platform = FakeStoreKit2Platform(); @@ -70,4 +82,73 @@ void main() { expect(response.error!.details, {'info': 'error_info'}); }); }); + + group('make payment', () { + test( + 'buying non consumable, should get purchase objects in the purchase update callback', + () async { + final List details = []; + final Completer> completer = + Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen((List purchaseDetailsList) { + details.addAll(purchaseDetailsList); + if (purchaseDetailsList.first.status == PurchaseStatus.purchased) { + completer.complete(details); + subscription.cancel(); + } + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProduct2Details.fromSK2Product(dummyProductWrapper), + applicationUserName: 'appName'); + await iapStoreKitPlatform.buyNonConsumable(purchaseParam: purchaseParam); + + final List result = await completer.future; + expect(result.length, 1); + expect(result.first.productID, dummyProductWrapper.id); + }); + + test( + 'buying consumable, should get purchase objects in the purchase update callback', + () async { + final List details = []; + final Completer> completer = + Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen((List purchaseDetailsList) { + details.addAll(purchaseDetailsList); + if (purchaseDetailsList.first.status == PurchaseStatus.purchased) { + completer.complete(details); + subscription.cancel(); + } + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProduct2Details.fromSK2Product(dummyProductWrapper), + applicationUserName: 'appName'); + await iapStoreKitPlatform.buyConsumable(purchaseParam: purchaseParam); + + final List result = await completer.future; + expect(result.length, 1); + expect(result.first.productID, dummyProductWrapper.id); + }); + + test('buying consumable, should throw when autoConsume is false', () async { + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProduct2Details.fromSK2Product(dummyProductWrapper), + applicationUserName: 'appName'); + expect( + () => iapStoreKitPlatform.buyConsumable( + purchaseParam: purchaseParam, autoConsume: false), + throwsA(isInstanceOf())); + }); + }); } 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 21ad0789066..2bd0201d9f4 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 @@ -32,20 +32,32 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is SK2SubscriptionPeriodUnitMessage) { buffer.putUint8(132); writeValue(buffer, value.index); - } else if (value is SK2SubscriptionOfferMessage) { + } else if (value is SK2ProductPurchaseResultMessage) { buffer.putUint8(133); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferMessage) { + buffer.putUint8(134); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionPeriodMessage) { - buffer.putUint8(134); + buffer.putUint8(135); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionInfoMessage) { - buffer.putUint8(135); + buffer.putUint8(136); writeValue(buffer, value.encode()); } else if (value is SK2ProductMessage) { - buffer.putUint8(136); + buffer.putUint8(137); writeValue(buffer, value.encode()); } else if (value is SK2PriceLocaleMessage) { - buffer.putUint8(137); + buffer.putUint8(138); + writeValue(buffer, value.encode()); + } else if (value is SK2ProductPurchaseOptionsMessage) { + buffer.putUint8(139); + writeValue(buffer, value.encode()); + } else if (value is SK2TransactionMessage) { + buffer.putUint8(140); + writeValue(buffer, value.encode()); + } else if (value is SK2ErrorMessage) { + buffer.putUint8(141); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -74,15 +86,26 @@ class _PigeonCodec extends StandardMessageCodec { ? null : SK2SubscriptionPeriodUnitMessage.values[value]; case 133: - return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null + ? null + : SK2ProductPurchaseResultMessage.values[value]; case 134: - return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); case 135: - return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); + return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); case 136: - return SK2ProductMessage.decode(readValue(buffer)!); + return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); case 137: + return SK2ProductMessage.decode(readValue(buffer)!); + case 138: return SK2PriceLocaleMessage.decode(readValue(buffer)!); + case 139: + return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); + case 140: + return SK2TransactionMessage.decode(readValue(buffer)!); + case 141: + return SK2ErrorMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -98,6 +121,17 @@ abstract class TestInAppPurchase2Api { Future> products(List identifiers); + Future purchase(String id, + {SK2ProductPurchaseOptionsMessage? options}); + + Future> transactions(); + + Future finish(int id); + + void startListeningToTransactions(); + + void stopListeningToTransactions(); + static void setUp( TestInAppPurchase2Api? api, { BinaryMessenger? binaryMessenger, @@ -165,5 +199,151 @@ abstract class TestInAppPurchase2Api { }); } } + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.purchase$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.purchase was null.'); + final List args = (message as List?)!; + final String? arg_id = (args[0] as String?); + assert(arg_id != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.purchase was null, expected non-null String.'); + final SK2ProductPurchaseOptionsMessage? arg_options = + (args[1] as SK2ProductPurchaseOptionsMessage?); + try { + final SK2ProductPurchaseResultMessage output = + await api.purchase(arg_id!, options: arg_options); + 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.transactions$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + try { + final List output = + await api.transactions(); + 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.finish$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.finish was null.'); + final List args = (message as List?)!; + final int? arg_id = (args[0] as int?); + assert(arg_id != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish was null, expected non-null int.'); + try { + await api.finish(arg_id!); + return wrapResponse(empty: true); + } 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.startListeningToTransactions$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + try { + api.startListeningToTransactions(); + return wrapResponse(empty: true); + } 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.stopListeningToTransactions$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + try { + api.stopListeningToTransactions(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } }