[in_app_purchase_storekit] Group purchases into a single event in storekit2 (#12237)
*Replace this paragraph with a description of what this PR is changing or adding, and why. Consider including before/after screenshots.*
*List which issues are fixed by this PR. You must list at least one issue.*
Fixes https://github.com/flutter/flutter/issues/187355
## Pre-Review Checklist
**Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.
[^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
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 ac77019..fa3b245 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.4.11+1
+
+* Fixes StoreKit 2 restore transactions not grouping purchases into a single event.
+
## 0.4.11
* Fixes StoreKit 2 date format does not match in_app_purchase_platform_interface PurchaseDetails.transactionDate format.
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 38e5f02..c5418b9 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
@@ -279,15 +279,16 @@
Task { [weak self] in
guard let self = self else { return }
do {
+ var restoredTransactions: [SK2TransactionMessage] = []
var unverifiedPurchases: [UInt64: (receipt: String, error: Error?)] = [:]
for await completedPurchase in Transaction.currentEntitlements {
switch completedPurchase {
case .verified(let purchase):
- self.sendTransactionUpdate(
- productId: purchase.productID,
- transaction: purchase,
- receipt: "\(completedPurchase.jwsRepresentation)",
- status: .restored
+ restoredTransactions.append(
+ purchase.convertToPigeon(
+ receipt: "\(completedPurchase.jwsRepresentation)",
+ status: .restored
+ )
)
case .unverified(let failedPurchase, let error):
unverifiedPurchases[failedPurchase.id] = (
@@ -295,6 +296,7 @@
)
}
}
+ self.sendTransactionUpdates(restoredTransactions)
if !unverifiedPurchases.isEmpty {
completion(
.failure(
@@ -303,6 +305,7 @@
message:
"This purchase could not be restored.",
details: unverifiedPurchases)))
+ return
}
completion(.success(Void()))
}
@@ -473,8 +476,12 @@
)
}
+ sendTransactionUpdates([transactionMessage])
+ }
+
+ private func sendTransactionUpdates(_ transactionMessages: [SK2TransactionMessage]) {
Task { @MainActor in
- self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) {
+ self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: transactionMessages) {
result in
switch result {
case .success: break
diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift
index e1d3390..3b1147a 100644
--- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift
+++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift
@@ -420,6 +420,52 @@
XCTAssert(callback.lastUpdate.first?.status == .restored)
}
+ func testRestoreMultipleProductsEmitsSingleBatchedUpdate() async throws {
+ // Purchase two subscriptions from different subscription groups so that
+ // both persist in `currentEntitlements` and restoring returns two
+ // transactions.
+ let firstPurchaseExpectation = self.expectation(description: "First purchase should succeed")
+ plugin.purchase(id: "subscription_discounted", options: nil) { result in
+ switch result {
+ case .success:
+ firstPurchaseExpectation.fulfill()
+ case .failure(let error):
+ XCTFail("Purchase should NOT fail. Failed with \(error)")
+ }
+ }
+ await fulfillment(of: [firstPurchaseExpectation], timeout: 5)
+
+ let secondPurchaseExpectation = self.expectation(description: "Second purchase should succeed")
+ plugin.purchase(id: "subscription_silver", options: nil) { result in
+ switch result {
+ case .success:
+ secondPurchaseExpectation.fulfill()
+ case .failure(let error):
+ XCTFail("Purchase should NOT fail. Failed with \(error)")
+ }
+ }
+ await fulfillment(of: [secondPurchaseExpectation], timeout: 5)
+
+ let restoreExpectation = self.expectation(description: "Restore request should succeed")
+ plugin.restorePurchases { result in
+ switch result {
+ case .success():
+ restoreExpectation.fulfill()
+ case .failure(let error):
+ XCTFail("Restore purchases should NOT fail. Failed with \(error)")
+ }
+ }
+ await fulfillment(of: [restoreExpectation], timeout: 5)
+
+ // Both restored transactions must arrive in a single `onTransactionsUpdated`
+ // callback.
+ XCTAssertEqual(callback.lastUpdate.count, 2)
+ XCTAssertTrue(callback.lastUpdate.allSatisfy { $0.status == .restored })
+ XCTAssertEqual(
+ Set(callback.lastUpdate.map { $0.productId }),
+ ["subscription_discounted", "subscription_silver"])
+ }
+
func testFinishTransaction() async throws {
let purchaseExpectation = self.expectation(description: "Purchase should succeed")
let finishExpectation = self.expectation(description: "Finishing purchase should succeed")
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 441ec10..1d76f7c 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 @@
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.11
+version: 0.4.11+1
environment:
sdk: ^3.10.0