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 b3c9557f21b..3f220f595ac 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,7 @@ -## NEXT +## 0.4.8 +* Fixes an issue causing StoreKit2 purchases to be reported as `restored` and left in an + unfinished state, due to `pendingCompletePurchase` being false. * Fixes Xcode 26.2 analyzer warnings in example app tests. ## 0.4.7 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 f6d72996517..d7a1cd9fba0 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 @@ -261,7 +261,8 @@ extension InAppPurchasePlugin: InAppPurchase2API { switch completedPurchase { case .verified(let purchase): self.sendTransactionUpdate( - transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)") + transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)", + restoring: true) case .unverified(let failedPurchase, let error): unverifiedPurchases[failedPurchase.id] = ( receipt: completedPurchase.jwsRepresentation, error: error @@ -354,8 +355,10 @@ extension InAppPurchasePlugin: InAppPurchase2API { } /// 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) + private func sendTransactionUpdate( + transaction: Transaction, receipt: String? = nil, restoring: Bool = false + ) { + let transactionMessage = transaction.convertToPigeon(receipt: receipt, restoring: restoring) Task { @MainActor in self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) { result in 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 19f18688e20..d71af427414 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,7 @@ extension Product.PurchaseResult { @available(iOS 15.0, macOS 12.0, *) extension Transaction { - func convertToPigeon(receipt: String?) -> SK2TransactionMessage { + func convertToPigeon(receipt: String?, restoring: Bool = false) -> SK2TransactionMessage { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" @@ -205,7 +205,7 @@ extension Transaction { expirationDate: expirationDate.map { dateFormatter.string(from: $0) }, purchasedQuantity: Int64(purchasedQuantity), appAccountToken: appAccountToken?.uuidString, - restoring: receipt != nil, + restoring: restoring, receiptData: receipt, jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self) ) 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 2a34c959bc8..095471d8b8e 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 @@ -8,10 +8,14 @@ import XCTest @testable import in_app_purchase_storekit final class FakeIAP2Callback: InAppPurchase2CallbackAPIProtocol { + + public var lastUpdate: [in_app_purchase_storekit.SK2TransactionMessage] = [] + func onTransactionsUpdated( newTransactions newTransactionsArg: [in_app_purchase_storekit.SK2TransactionMessage], completion: @escaping (Result) -> Void ) { + lastUpdate = newTransactionsArg // We should only write to a flutter channel from the main thread. XCTAssertTrue(Thread.isMainThread) } @@ -21,6 +25,7 @@ final class FakeIAP2Callback: InAppPurchase2CallbackAPIProtocol { final class InAppPurchase2PluginTests: XCTestCase { private var session: SKTestSession! private var plugin: InAppPurchasePlugin! + private var callback: FakeIAP2Callback = FakeIAP2Callback() override func setUp() async throws { try await super.setUp() @@ -33,7 +38,7 @@ final class InAppPurchase2PluginTests: XCTestCase { plugin = InAppPurchasePluginStub(receiptManager: FIAPReceiptManagerStub()) { request in DefaultRequestHandler(requestHandler: FIAPRequestHandler(request: request)) } - plugin.transactionCallbackAPI = FakeIAP2Callback() + plugin.transactionCallbackAPI = callback try plugin.startListeningToTransactions() } @@ -86,11 +91,17 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [purchaseExpectation], timeout: 5) + XCTAssert(callback.lastUpdate.count == 1) + XCTAssert( + callback.lastUpdate.first?.restoring == false, + "Ordinary purchase updates should not be marked as restoring") + plugin.transactions { result in switch result { case .success(let transactions): XCTAssert(transactions.count == 1) + XCTAssert(transactions.first?.restoring == false) transactionExpectation.fulfill() case .failure(let error): XCTFail("Getting transactions should NOT fail. Failed with \(error)") @@ -376,6 +387,9 @@ final class InAppPurchase2PluginTests: XCTestCase { } await fulfillment(of: [purchaseExpectation], timeout: 5) + XCTAssert(callback.lastUpdate.count == 1) + XCTAssert(callback.lastUpdate.first?.restoring == false) + plugin.restorePurchases { result in switch result { case .success(): @@ -385,6 +399,9 @@ final class InAppPurchase2PluginTests: XCTestCase { } } await fulfillment(of: [restoreExpectation], timeout: 5) + + XCTAssert(callback.lastUpdate.count == 1) + XCTAssert(callback.lastUpdate.first?.restoring == true) } func testFinishTransaction() async throws { 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 5882c914518..44774ace5b8 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.8 environment: sdk: ^3.9.0 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 35191b98a20..cc95c167b80 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 @@ -120,6 +120,8 @@ void main() { final List result = await completer.future; expect(result.length, 1); expect(result.first.productID, dummyProductWrapper.id); + expect(result.first.status, PurchaseStatus.purchased); + expect(result.first.pendingCompletePurchase, true); }, );