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 35217311720b..37ac16893259 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.10+2 + +* Fixes StoreKi 2 restore transactions not grouping purchases into a single event. + ## 0.4.10+1 * Fixes SK2Transaction to expose the real purchased quantity instead of defaulting to 1. 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 38e5f026615b..0f2d46cf8c50 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 @@ extension InAppPurchasePlugin: InAppPurchase2API { 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] = ( @@ -303,7 +304,9 @@ extension InAppPurchasePlugin: InAppPurchase2API { message: "This purchase could not be restored.", details: unverifiedPurchases))) + return } + self.sendTransactionUpdates(restoredTransactions) completion(.success(Void())) } } @@ -473,8 +476,12 @@ extension InAppPurchasePlugin: InAppPurchase2API { ) } + 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 e1d3390282f4..3b1147abe640 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 @@ final class InAppPurchase2PluginTests: XCTestCase { 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 ceba6391adaa..39b75d95ee90 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.10+1 +version: 0.4.10+2 environment: sdk: ^3.10.0