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 b3c9557f21b5..69e97b46498a 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.4.7+1 +* Fixes StoreKit 2 purchase flow to send cancelled/pending/unverified results to `purchaseStream`. * Fixes Xcode 26.2 analyzer warnings in example app tests. ## 0.4.7 @@ -18,7 +19,7 @@ ## 0.4.6 * Adds a new case `.unverified` to enum `SK2ProductPurchaseResult` -* Fixes the StoreKit2 implementation throwing `PlatformException`s instead of returning the corresponding +* Fixes the StoreKit2 implementation throwing `PlatformException`s instead of returning the corresponding `SK2ProductPurchaseResult` when a purchase is cancelled / unverified / pending. ## 0.4.5 @@ -47,7 +48,7 @@ * Updates minimum supported SDK version to Flutter 3.27/Dart 3.6. * Adds **Win Back Offers** support for StoreKit2: - - Includes new `isWinBackOfferEligible` function for eligibility verification + * Includes new `isWinBackOfferEligible` function for eligibility verification * Adds **Promotional Offers** support in StoreKit2 purchases * Fixes introductory pricing handling in promotional offers list in StoreKit2 * Ensures proper `appAccountToken` handling for StoreKit2 purchases diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index f6d729965174..3b4ceac802c8 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -90,9 +90,15 @@ extension InAppPurchasePlugin: InAppPurchase2API { switch result { case .success(let verification): sendTransactionUpdate( - transaction: verification.unsafePayloadValue, receipt: verification.jwsRepresentation) - case .pending, .userCancelled: - break + productId: id, + transaction: verification.unsafePayloadValue, + receipt: verification.jwsRepresentation, + status: .purchased + ) + case .pending: + sendTransactionUpdate(productId: id, status: .pending) + case .userCancelled: + sendTransactionUpdate(productId: id, status: .cancelled) @unknown default: fatalError("An unknown StoreKit PurchaseResult has been encountered.") } @@ -223,7 +229,7 @@ extension InAppPurchasePlugin: InAppPurchase2API { @MainActor in do { let transactionsMsgs = await rawTransactions().map { - $0.convertToPigeon(receipt: nil) + $0.convertToPigeon(receipt: nil, status: .purchased) } completion(.success(transactionsMsgs)) } @@ -242,7 +248,8 @@ extension InAppPurchasePlugin: InAppPurchase2API { switch verificationResult { case .verified(let transaction): transactionsMsgs.append( - transaction.convertToPigeon(receipt: verificationResult.jwsRepresentation) + transaction.convertToPigeon( + receipt: verificationResult.jwsRepresentation, status: .purchased) ) case .unverified: break @@ -261,7 +268,11 @@ extension InAppPurchasePlugin: InAppPurchase2API { switch completedPurchase { case .verified(let purchase): self.sendTransactionUpdate( - transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)") + productId: purchase.productID, + transaction: purchase, + receipt: "\(completedPurchase.jwsRepresentation)", + status: .restored + ) case .unverified(let failedPurchase, let error): unverifiedPurchases[failedPurchase.id] = ( receipt: completedPurchase.jwsRepresentation, error: error @@ -340,7 +351,11 @@ extension InAppPurchasePlugin: InAppPurchase2API { switch verificationResult { case .verified(let transaction): self?.sendTransactionUpdate( - transaction: transaction, receipt: verificationResult.jwsRepresentation) + productId: transaction.productID, + transaction: transaction, + receipt: verificationResult.jwsRepresentation, + status: .purchased + ) case .unverified: break } @@ -353,16 +368,41 @@ extension InAppPurchasePlugin: InAppPurchase2API { updateListenerTask.cancel() } - /// Sends an transaction back to Dart. Access these transactions with `purchaseStream` - private func sendTransactionUpdate(transaction: Transaction, receipt: String? = nil) { - let transactionMessage = transaction.convertToPigeon(receipt: receipt) + /// Sends a transaction or status update back to Dart. Access these transactions with `purchaseStream` + /// - Parameters: + /// - productId: The product ID (required) + /// - transaction: The transaction object (for success cases, nil for pending/cancelled) + /// - receipt: The JWS receipt data + /// - status: The purchase status + private func sendTransactionUpdate( + productId: String, + transaction: Transaction? = nil, + receipt: String? = nil, + status: SK2PurchaseStatusMessage + ) { + let transactionMessage: SK2TransactionMessage + + if let transaction = transaction { + // Has real transaction: use transaction info + transactionMessage = transaction.convertToPigeon(receipt: receipt, status: status) + } else { + // No transaction (pending/cancelled): create minimal message without purchaseDate + transactionMessage = SK2TransactionMessage( + id: 0, + originalId: 0, + productId: productId, + purchasedQuantity: 1, + status: status + ) + } + Task { @MainActor in self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) { result in switch result { case .success: break case .failure(let error): - print("Failed to send transaction updates: \(error)") + print("Failed to send transaction update: \(error)") } } } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift index eba2f16eabe1..81691c5e071e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.1), do not edit directly. +// Autogenerated from Pigeon (v26.1.5), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -172,6 +172,19 @@ enum SK2ProductPurchaseResultMessage: Int { case pending = 3 } +/// The status of a purchase transaction. +/// Used to communicate the result state to Dart layer via purchaseStream. +enum SK2PurchaseStatusMessage: Int { + /// Purchase completed successfully. + case purchased = 0 + /// Purchase is pending (e.g., Ask to Buy). + case pending = 1 + /// Purchase was cancelled by the user. + case cancelled = 2 + /// Purchase was restored. + case restored = 3 +} + /// Generated class from Pigeon that represents data sent in messages. struct SK2SubscriptionOfferMessage: Hashable { var id: String? = nil @@ -494,28 +507,30 @@ struct SK2TransactionMessage: Hashable { var id: Int64 var originalId: Int64 var productId: String - var purchaseDate: String + var purchaseDate: String? = nil var expirationDate: String? = nil var purchasedQuantity: Int64 var appAccountToken: String? = nil - var restoring: Bool var receiptData: String? = nil var error: SK2ErrorMessage? = nil var jsonRepresentation: String? = nil + /// The status of this purchase transaction. + /// Set by native side to communicate the result state to Dart layer. + var status: SK2PurchaseStatusMessage // swift-format-ignore: AlwaysUseLowerCamelCase static func fromList(_ pigeonVar_list: [Any?]) -> SK2TransactionMessage? { let id = pigeonVar_list[0] as! Int64 let originalId = pigeonVar_list[1] as! Int64 let productId = pigeonVar_list[2] as! String - let purchaseDate = pigeonVar_list[3] as! String + let purchaseDate: String? = nilOrValue(pigeonVar_list[3]) let expirationDate: String? = nilOrValue(pigeonVar_list[4]) let purchasedQuantity = pigeonVar_list[5] as! Int64 let appAccountToken: String? = nilOrValue(pigeonVar_list[6]) - let restoring = pigeonVar_list[7] as! Bool - let receiptData: String? = nilOrValue(pigeonVar_list[8]) - let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[9]) - let jsonRepresentation: String? = nilOrValue(pigeonVar_list[10]) + let receiptData: String? = nilOrValue(pigeonVar_list[7]) + let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[8]) + let jsonRepresentation: String? = nilOrValue(pigeonVar_list[9]) + let status = pigeonVar_list[10] as! SK2PurchaseStatusMessage return SK2TransactionMessage( id: id, @@ -525,10 +540,10 @@ struct SK2TransactionMessage: Hashable { expirationDate: expirationDate, purchasedQuantity: purchasedQuantity, appAccountToken: appAccountToken, - restoring: restoring, receiptData: receiptData, error: error, - jsonRepresentation: jsonRepresentation + jsonRepresentation: jsonRepresentation, + status: status ) } func toList() -> [Any?] { @@ -540,10 +555,10 @@ struct SK2TransactionMessage: Hashable { expirationDate, purchasedQuantity, appAccountToken, - restoring, receiptData, error, jsonRepresentation, + status, ] } static func == (lhs: SK2TransactionMessage, rhs: SK2TransactionMessage) -> Bool { @@ -621,24 +636,30 @@ private class StoreKit2MessagesPigeonCodecReader: FlutterStandardReader { } return nil case 134: - return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?]) + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return SK2PurchaseStatusMessage(rawValue: enumResultAsInt) + } + return nil case 135: - return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?]) case 136: - return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?]) case 137: - return SK2ProductMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?]) case 138: - return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?]) + return SK2ProductMessage.fromList(self.readValue() as! [Any?]) case 139: - return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?]) + return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?]) case 140: - return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?]) case 141: - return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?]) + return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?]) case 142: - return SK2TransactionMessage.fromList(self.readValue() as! [Any?]) + return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?]) case 143: + return SK2TransactionMessage.fromList(self.readValue() as! [Any?]) + case 144: return SK2ErrorMessage.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -663,35 +684,38 @@ private class StoreKit2MessagesPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? SK2ProductPurchaseResultMessage { super.writeByte(133) super.writeValue(value.rawValue) - } else if let value = value as? SK2SubscriptionOfferMessage { + } else if let value = value as? SK2PurchaseStatusMessage { super.writeByte(134) + super.writeValue(value.rawValue) + } else if let value = value as? SK2SubscriptionOfferMessage { + super.writeByte(135) super.writeValue(value.toList()) } else if let value = value as? SK2SubscriptionPeriodMessage { - super.writeByte(135) + super.writeByte(136) super.writeValue(value.toList()) } else if let value = value as? SK2SubscriptionInfoMessage { - super.writeByte(136) + super.writeByte(137) super.writeValue(value.toList()) } else if let value = value as? SK2ProductMessage { - super.writeByte(137) + super.writeByte(138) super.writeValue(value.toList()) } else if let value = value as? SK2PriceLocaleMessage { - super.writeByte(138) + super.writeByte(139) super.writeValue(value.toList()) } else if let value = value as? SK2SubscriptionOfferSignatureMessage { - super.writeByte(139) + super.writeByte(140) super.writeValue(value.toList()) } else if let value = value as? SK2SubscriptionOfferPurchaseMessage { - super.writeByte(140) + super.writeByte(141) super.writeValue(value.toList()) } else if let value = value as? SK2ProductPurchaseOptionsMessage { - super.writeByte(141) + super.writeByte(142) super.writeValue(value.toList()) } else if let value = value as? SK2TransactionMessage { - super.writeByte(142) + super.writeByte(143) super.writeValue(value.toList()) } else if let value = value as? SK2ErrorMessage { - super.writeByte(143) + super.writeByte(144) super.writeValue(value.toList()) } else { super.writeValue(value) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift index 19f18688e208..ae2cf5530d50 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift @@ -192,7 +192,8 @@ extension Product.PurchaseResult { @available(iOS 15.0, macOS 12.0, *) extension Transaction { - func convertToPigeon(receipt: String?) -> SK2TransactionMessage { + func convertToPigeon(receipt: String?, status: SK2PurchaseStatusMessage) -> SK2TransactionMessage + { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" @@ -205,9 +206,9 @@ extension Transaction { expirationDate: expirationDate.map { dateFormatter.string(from: $0) }, purchasedQuantity: Int64(purchasedQuantity), appAccountToken: appAccountToken?.uuidString, - restoring: receipt != nil, receiptData: receipt, - jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self) + jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self), + status: status ) } } 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 9f0c99567042..8b20981d7d80 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart @@ -1,9 +1,9 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.1), do not edit directly. +// Autogenerated from Pigeon (v26.1.5), 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 +// 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, omit_obvious_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; @@ -81,6 +81,22 @@ enum SK2ProductPurchaseResultMessage { pending, } +/// The status of a purchase transaction. +/// Used to communicate the result state to Dart layer via purchaseStream. +enum SK2PurchaseStatusMessage { + /// Purchase completed successfully. + purchased, + + /// Purchase is pending (e.g., Ask to Buy). + pending, + + /// Purchase was cancelled by the user. + cancelled, + + /// Purchase was restored. + restored, +} + class SK2SubscriptionOfferMessage { SK2SubscriptionOfferMessage({ this.id, @@ -530,14 +546,14 @@ class SK2TransactionMessage { required this.id, required this.originalId, required this.productId, - required this.purchaseDate, + this.purchaseDate, this.expirationDate, this.purchasedQuantity = 1, this.appAccountToken, - this.restoring = false, this.receiptData, this.error, this.jsonRepresentation, + required this.status, }); int id; @@ -546,7 +562,7 @@ class SK2TransactionMessage { String productId; - String purchaseDate; + String? purchaseDate; String? expirationDate; @@ -554,14 +570,16 @@ class SK2TransactionMessage { String? appAccountToken; - bool restoring; - String? receiptData; SK2ErrorMessage? error; String? jsonRepresentation; + /// The status of this purchase transaction. + /// Set by native side to communicate the result state to Dart layer. + SK2PurchaseStatusMessage status; + List _toList() { return [ id, @@ -571,10 +589,10 @@ class SK2TransactionMessage { expirationDate, purchasedQuantity, appAccountToken, - restoring, receiptData, error, jsonRepresentation, + status, ]; } @@ -588,14 +606,14 @@ class SK2TransactionMessage { id: result[0]! as int, originalId: result[1]! as int, productId: result[2]! as String, - purchaseDate: result[3]! as String, + purchaseDate: result[3] as String?, expirationDate: result[4] as String?, purchasedQuantity: result[5]! as int, appAccountToken: result[6] as String?, - restoring: result[7]! as bool, - receiptData: result[8] as String?, - error: result[9] as SK2ErrorMessage?, - jsonRepresentation: result[10] as String?, + receiptData: result[7] as String?, + error: result[8] as SK2ErrorMessage?, + jsonRepresentation: result[9] as String?, + status: result[10]! as SK2PurchaseStatusMessage, ); } @@ -681,35 +699,38 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is SK2ProductPurchaseResultMessage) { buffer.putUint8(133); writeValue(buffer, value.index); - } else if (value is SK2SubscriptionOfferMessage) { + } else if (value is SK2PurchaseStatusMessage) { buffer.putUint8(134); + writeValue(buffer, value.index); + } else if (value is SK2SubscriptionOfferMessage) { + buffer.putUint8(135); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionPeriodMessage) { - buffer.putUint8(135); + buffer.putUint8(136); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionInfoMessage) { - buffer.putUint8(136); + buffer.putUint8(137); writeValue(buffer, value.encode()); } else if (value is SK2ProductMessage) { - buffer.putUint8(137); + buffer.putUint8(138); writeValue(buffer, value.encode()); } else if (value is SK2PriceLocaleMessage) { - buffer.putUint8(138); + buffer.putUint8(139); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionOfferSignatureMessage) { - buffer.putUint8(139); + buffer.putUint8(140); writeValue(buffer, value.encode()); } else if (value is SK2SubscriptionOfferPurchaseMessage) { - buffer.putUint8(140); + buffer.putUint8(141); writeValue(buffer, value.encode()); } else if (value is SK2ProductPurchaseOptionsMessage) { - buffer.putUint8(141); + buffer.putUint8(142); writeValue(buffer, value.encode()); } else if (value is SK2TransactionMessage) { - buffer.putUint8(142); + buffer.putUint8(143); writeValue(buffer, value.encode()); } else if (value is SK2ErrorMessage) { - buffer.putUint8(143); + buffer.putUint8(144); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -720,47 +741,50 @@ class _PigeonCodec extends StandardMessageCodec { Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { case 129: - final int? value = readValue(buffer) as int?; + final value = readValue(buffer) as int?; return value == null ? null : SK2ProductTypeMessage.values[value]; case 130: - final int? value = readValue(buffer) as int?; + final value = readValue(buffer) as int?; return value == null ? null : SK2SubscriptionOfferTypeMessage.values[value]; case 131: - final int? value = readValue(buffer) as int?; + final value = readValue(buffer) as int?; return value == null ? null : SK2SubscriptionOfferPaymentModeMessage.values[value]; case 132: - final int? value = readValue(buffer) as int?; + final value = readValue(buffer) as int?; return value == null ? null : SK2SubscriptionPeriodUnitMessage.values[value]; case 133: - final int? value = readValue(buffer) as int?; + final value = readValue(buffer) as int?; return value == null ? null : SK2ProductPurchaseResultMessage.values[value]; case 134: - return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); + final value = readValue(buffer) as int?; + return value == null ? null : SK2PurchaseStatusMessage.values[value]; case 135: - return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferMessage.decode(readValue(buffer)!); case 136: - return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); + return SK2SubscriptionPeriodMessage.decode(readValue(buffer)!); case 137: - return SK2ProductMessage.decode(readValue(buffer)!); + return SK2SubscriptionInfoMessage.decode(readValue(buffer)!); case 138: - return SK2PriceLocaleMessage.decode(readValue(buffer)!); + return SK2ProductMessage.decode(readValue(buffer)!); case 139: - return SK2SubscriptionOfferSignatureMessage.decode(readValue(buffer)!); + return SK2PriceLocaleMessage.decode(readValue(buffer)!); case 140: - return SK2SubscriptionOfferPurchaseMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferSignatureMessage.decode(readValue(buffer)!); case 141: - return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); + return SK2SubscriptionOfferPurchaseMessage.decode(readValue(buffer)!); case 142: - return SK2TransactionMessage.decode(readValue(buffer)!); + return SK2ProductPurchaseOptionsMessage.decode(readValue(buffer)!); case 143: + return SK2TransactionMessage.decode(readValue(buffer)!); + case 144: return SK2ErrorMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -786,17 +810,15 @@ class InAppPurchase2API { final String pigeonVar_messageChannelSuffix; Future canMakePayments() async { - final String pigeonVar_channelName = + final 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 pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -816,19 +838,17 @@ class InAppPurchase2API { } Future> products(List identifiers) async { - final String pigeonVar_channelName = + final 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 pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send( [identifiers], ); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -852,19 +872,17 @@ class InAppPurchase2API { String id, { SK2ProductPurchaseOptionsMessage? options, }) async { - final String pigeonVar_channelName = + final 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 pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send( [id, options], ); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -884,19 +902,17 @@ class InAppPurchase2API { } Future isWinBackOfferEligible(String productId, String offerId) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send( [productId, offerId], ); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -916,19 +932,17 @@ class InAppPurchase2API { } Future isIntroductoryOfferEligible(String productId) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send( [productId], ); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -948,17 +962,15 @@ class InAppPurchase2API { } Future> transactions() async { - final String pigeonVar_channelName = + final 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 pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -979,17 +991,15 @@ class InAppPurchase2API { } Future> unfinishedTransactions() async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.unfinishedTransactions$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1010,19 +1020,17 @@ class InAppPurchase2API { } Future finish(int id) async { - final String pigeonVar_channelName = + final 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 pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send( [id], ); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1037,17 +1045,15 @@ class InAppPurchase2API { } Future startListeningToTransactions() async { - final String pigeonVar_channelName = + final 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 pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1062,17 +1068,15 @@ class InAppPurchase2API { } Future stopListeningToTransactions() async { - final String pigeonVar_channelName = + final 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 pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1087,17 +1091,15 @@ class InAppPurchase2API { } Future restorePurchases() async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.restorePurchases$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1112,17 +1114,15 @@ class InAppPurchase2API { } Future countryCode() async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.countryCode$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1142,17 +1142,15 @@ class InAppPurchase2API { } Future sync() async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.sync$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1181,8 +1179,7 @@ abstract class InAppPurchase2CallbackAPI { ? '.$messageChannelSuffix' : ''; { - final BasicMessageChannel - pigeonVar_channel = BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated$messageChannelSuffix', pigeonChannelCodec, binaryMessenger: binaryMessenger, 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 index 55305cc4b4ee..551d4ed0836e 100644 --- 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 @@ -42,7 +42,7 @@ class SK2Transaction { /// 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 + /// 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; @@ -125,7 +125,7 @@ extension on SK2TransactionMessage { id: id.toString(), originalId: originalId.toString(), productId: productId, - purchaseDate: purchaseDate, + purchaseDate: purchaseDate ?? '', expirationDate: expirationDate, appAccountToken: appAccountToken, receiptData: receiptData, @@ -134,11 +134,19 @@ extension on SK2TransactionMessage { } PurchaseDetails convertToDetails() { + // Determine PurchaseStatus based on the status field from native side + final PurchaseStatus purchaseStatus = switch (status) { + SK2PurchaseStatusMessage.purchased => PurchaseStatus.purchased, + SK2PurchaseStatusMessage.pending => PurchaseStatus.pending, + SK2PurchaseStatusMessage.cancelled => PurchaseStatus.canceled, + SK2PurchaseStatusMessage.restored => PurchaseStatus.restored, + }; + 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 + // receipt isn't necessary with SK2 as a Transaction can only be returned // from validated purchases. verificationData: PurchaseVerificationData( localVerificationData: jsonRepresentation ?? '', @@ -147,12 +155,8 @@ extension on SK2TransactionMessage { source: kIAPSource, ), 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(), + status: purchaseStatus, + purchaseID: id > 0 ? id.toString() : null, appAccountToken: appAccountToken, ); } 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 780ad63779a9..f318ec5277e0 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 @@ -174,26 +174,29 @@ class SK2TransactionMessage { required this.id, required this.originalId, required this.productId, - required this.purchaseDate, + this.purchaseDate, this.expirationDate, this.purchasedQuantity = 1, this.appAccountToken, this.error, this.receiptData, this.jsonRepresentation, - this.restoring = false, + required this.status, }); final int id; final int originalId; final String productId; - final String purchaseDate; + final String? purchaseDate; final String? expirationDate; final int purchasedQuantity; final String? appAccountToken; - final bool restoring; final String? receiptData; final SK2ErrorMessage? error; final String? jsonRepresentation; + + /// The status of this purchase transaction. + /// Set by native side to communicate the result state to Dart layer. + final SK2PurchaseStatusMessage status; } class SK2ErrorMessage { @@ -215,6 +218,22 @@ enum SK2ProductPurchaseResultMessage { pending, } +/// The status of a purchase transaction. +/// Used to communicate the result state to Dart layer via purchaseStream. +enum SK2PurchaseStatusMessage { + /// Purchase completed successfully. + purchased, + + /// Purchase is pending (e.g., Ask to Buy). + pending, + + /// Purchase was cancelled by the user. + cancelled, + + /// Purchase was restored. + restored, +} + @HostApi() abstract class InAppPurchase2API { // https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments 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 5882c9145184..b52c552a8207 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_storekit description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.4.7 +version: 0.4.7+1 environment: sdk: ^3.9.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 a188d3f7eceb..517670f8c8ae 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 @@ -358,6 +358,11 @@ class FakeStoreKit2Platform implements InAppPurchase2API { Map> eligibleWinBackOffers = >{}; Map eligibleIntroductoryOffers = {}; + /// Simulates purchase result for testing non-success scenarios. + /// Set to userCancelled, pending, or unverified to test those cases. + SK2ProductPurchaseResultMessage simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.success; + void reset() { validProductIDs = {'123', '456'}; validProducts = {}; @@ -375,6 +380,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API { } eligibleWinBackOffers = >{}; eligibleIntroductoryOffers = {}; + simulatedPurchaseResult = SK2ProductPurchaseResultMessage.success; } SK2TransactionMessage createRestoredTransaction( @@ -388,7 +394,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API { productId: '', purchaseDate: '', appAccountToken: '', - restoring: true, + status: SK2PurchaseStatusMessage.restored, ); } @@ -423,13 +429,50 @@ class FakeStoreKit2Platform implements InAppPurchase2API { SK2ProductPurchaseOptionsMessage? options, }) { lastPurchaseOptions = options; - final SK2TransactionMessage transaction = createPendingTransaction(id); - InAppPurchaseStoreKitPlatform.sk2TransactionObserver.onTransactionsUpdated( - [transaction], - ); + // Native side sends transaction update for success cases (both verified and unverified) + // Only userCancelled and pending don't have real transaction data + switch (simulatedPurchaseResult) { + case SK2ProductPurchaseResultMessage.success: + case SK2ProductPurchaseResultMessage.unverified: + final transaction = SK2TransactionMessage( + id: 1, + originalId: 2, + productId: id, + purchaseDate: 'purchaseDate', + appAccountToken: 'appAccountToken', + receiptData: 'receiptData', + jsonRepresentation: 'jsonRepresentation', + status: SK2PurchaseStatusMessage.purchased, + ); + InAppPurchaseStoreKitPlatform.sk2TransactionObserver + .onTransactionsUpdated([transaction]); + case SK2ProductPurchaseResultMessage.pending: + // Create minimal message for pending status (without purchaseDate) + final pendingTransaction = SK2TransactionMessage( + id: 0, + originalId: 0, + productId: id, + status: SK2PurchaseStatusMessage.pending, + ); + InAppPurchaseStoreKitPlatform.sk2TransactionObserver + .onTransactionsUpdated([pendingTransaction]); + case SK2ProductPurchaseResultMessage.userCancelled: + // Create minimal message for cancelled status (without purchaseDate) + final cancelledTransaction = SK2TransactionMessage( + id: 0, + originalId: 0, + productId: id, + status: SK2PurchaseStatusMessage.cancelled, + ); + InAppPurchaseStoreKitPlatform.sk2TransactionObserver + .onTransactionsUpdated([ + cancelledTransaction, + ]); + } + return Future.value( - SK2ProductPurchaseResultMessage.success, + simulatedPurchaseResult, ); } @@ -446,6 +489,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API { originalId: 123, productId: 'product_id', purchaseDate: '12-12', + status: SK2PurchaseStatusMessage.purchased, ), ]); } @@ -460,6 +504,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API { purchaseDate: '12-12', receiptData: 'fake_jws_representation', appAccountToken: 'fake_app_account_token', + status: SK2PurchaseStatusMessage.purchased, ), ]); } @@ -549,5 +594,6 @@ SK2TransactionMessage createPendingTransaction(String id, {int quantity = 1}) { appAccountToken: 'appAccountToken', receiptData: 'receiptData', jsonRepresentation: 'jsonRepresentation', + status: SK2PurchaseStatusMessage.purchased, ); } 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 35191b98a203..dedf8c4b57c4 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 @@ -343,6 +343,116 @@ void main() { expect(lastPurchaseOptions.quantity, 1); }, ); + + test( + 'user cancelled purchase should emit canceled status to purchaseStream', + () async { + fakeStoreKit2Platform.simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.userCancelled; + + final completer = Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen(( + List purchaseDetailsList, + ) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + }); + + final 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); + expect(result.first.status, PurchaseStatus.canceled); + expect(result.first.pendingCompletePurchase, false); + }, + ); + + test( + 'pending purchase should emit pending status to purchaseStream', + () async { + fakeStoreKit2Platform.simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.pending; + + final completer = Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen(( + List purchaseDetailsList, + ) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + }); + + final 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); + expect(result.first.status, PurchaseStatus.pending); + expect(result.first.pendingCompletePurchase, false); + }, + ); + + test( + 'unverified purchase should receive transaction with purchased status', + () async { + fakeStoreKit2Platform.simulatedPurchaseResult = + SK2ProductPurchaseResultMessage.unverified; + + final completer = Completer>(); + final Stream> stream = + iapStoreKitPlatform.purchaseStream; + + late StreamSubscription> subscription; + subscription = stream.listen(( + List purchaseDetailsList, + ) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + }); + + final 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); + // Native side sends the transaction for unverified case + // The transaction comes with purchased status from native side + expect(result.first.status, PurchaseStatus.purchased); + expect(result.first.pendingCompletePurchase, true); + }, + ); }); group('restore purchases', () {