Skip to content

Commit 6350518

Browse files
authored
[in_app_purchase] Fix an issue causing StoreKit 2 transactions remain unfinished (#10656)
The StoreKit 2 (iOS, MacOS) in_app_purchase backend causes ordinary client use (eg. the `in_app_purchase` example app) to leave transactions in an unfinished state, contrary to the StoreKit contract. This happens because StoreKit 2 purchase updates have `pendingCompletePurchase == false`, causing clients to not call `completePurchase` (flutter/flutter#180046). This in turn happens because purchase updates incorrectly have state `restored` instead of `purchased` (flutter/flutter#172434). Fix this by keeping track of which TransactionMessages are created in the restorePurchases code path to set the `restoring` field correctly. Previously it was set whenever a transaction had receipt data (which is also true for ordinary purchase updates). This keeps ordinary purcahses as state `purchased`. These purchase records will also have `pendingCompletePurchase` set to `true`, since this is gated by state `purchased`. Fixes: * flutter/flutter#180046 * flutter/flutter#172434 ## Pre-Review Checklist [^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.
1 parent a05d0b2 commit 6350518

6 files changed

Lines changed: 32 additions & 8 deletions

File tree

packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
## NEXT
1+
## 0.4.8
22

3+
* Fixes an issue causing StoreKit2 purchases to be reported as `restored` and left in an
4+
unfinished state, due to `pendingCompletePurchase` being false.
35
* Fixes Xcode 26.2 analyzer warnings in example app tests.
46

57
## 0.4.7

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ extension InAppPurchasePlugin: InAppPurchase2API {
261261
switch completedPurchase {
262262
case .verified(let purchase):
263263
self.sendTransactionUpdate(
264-
transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)")
264+
transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)",
265+
restoring: true)
265266
case .unverified(let failedPurchase, let error):
266267
unverifiedPurchases[failedPurchase.id] = (
267268
receipt: completedPurchase.jwsRepresentation, error: error
@@ -354,8 +355,10 @@ extension InAppPurchasePlugin: InAppPurchase2API {
354355
}
355356

356357
/// Sends an transaction back to Dart. Access these transactions with `purchaseStream`
357-
private func sendTransactionUpdate(transaction: Transaction, receipt: String? = nil) {
358-
let transactionMessage = transaction.convertToPigeon(receipt: receipt)
358+
private func sendTransactionUpdate(
359+
transaction: Transaction, receipt: String? = nil, restoring: Bool = false
360+
) {
361+
let transactionMessage = transaction.convertToPigeon(receipt: receipt, restoring: restoring)
359362
Task { @MainActor in
360363
self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) {
361364
result in

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ extension Product.PurchaseResult {
192192

193193
@available(iOS 15.0, macOS 12.0, *)
194194
extension Transaction {
195-
func convertToPigeon(receipt: String?) -> SK2TransactionMessage {
195+
func convertToPigeon(receipt: String?, restoring: Bool = false) -> SK2TransactionMessage {
196196

197197
let dateFormatter = DateFormatter()
198198
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
@@ -205,7 +205,7 @@ extension Transaction {
205205
expirationDate: expirationDate.map { dateFormatter.string(from: $0) },
206206
purchasedQuantity: Int64(purchasedQuantity),
207207
appAccountToken: appAccountToken?.uuidString,
208-
restoring: receipt != nil,
208+
restoring: restoring,
209209
receiptData: receipt,
210210
jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self)
211211
)

packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import XCTest
88
@testable import in_app_purchase_storekit
99

1010
final class FakeIAP2Callback: InAppPurchase2CallbackAPIProtocol {
11+
12+
public var lastUpdate: [in_app_purchase_storekit.SK2TransactionMessage] = []
13+
1114
func onTransactionsUpdated(
1215
newTransactions newTransactionsArg: [in_app_purchase_storekit.SK2TransactionMessage],
1316
completion: @escaping (Result<Void, in_app_purchase_storekit.PigeonError>) -> Void
1417
) {
18+
lastUpdate = newTransactionsArg
1519
// We should only write to a flutter channel from the main thread.
1620
XCTAssertTrue(Thread.isMainThread)
1721
}
@@ -21,6 +25,7 @@ final class FakeIAP2Callback: InAppPurchase2CallbackAPIProtocol {
2125
final class InAppPurchase2PluginTests: XCTestCase {
2226
private var session: SKTestSession!
2327
private var plugin: InAppPurchasePlugin!
28+
private var callback: FakeIAP2Callback = FakeIAP2Callback()
2429

2530
override func setUp() async throws {
2631
try await super.setUp()
@@ -33,7 +38,7 @@ final class InAppPurchase2PluginTests: XCTestCase {
3338
plugin = InAppPurchasePluginStub(receiptManager: FIAPReceiptManagerStub()) { request in
3439
DefaultRequestHandler(requestHandler: FIAPRequestHandler(request: request))
3540
}
36-
plugin.transactionCallbackAPI = FakeIAP2Callback()
41+
plugin.transactionCallbackAPI = callback
3742
try plugin.startListeningToTransactions()
3843
}
3944

@@ -86,11 +91,17 @@ final class InAppPurchase2PluginTests: XCTestCase {
8691

8792
await fulfillment(of: [purchaseExpectation], timeout: 5)
8893

94+
XCTAssert(callback.lastUpdate.count == 1)
95+
XCTAssert(
96+
callback.lastUpdate.first?.restoring == false,
97+
"Ordinary purchase updates should not be marked as restoring")
98+
8999
plugin.transactions {
90100
result in
91101
switch result {
92102
case .success(let transactions):
93103
XCTAssert(transactions.count == 1)
104+
XCTAssert(transactions.first?.restoring == false)
94105
transactionExpectation.fulfill()
95106
case .failure(let error):
96107
XCTFail("Getting transactions should NOT fail. Failed with \(error)")
@@ -376,6 +387,9 @@ final class InAppPurchase2PluginTests: XCTestCase {
376387
}
377388
await fulfillment(of: [purchaseExpectation], timeout: 5)
378389

390+
XCTAssert(callback.lastUpdate.count == 1)
391+
XCTAssert(callback.lastUpdate.first?.restoring == false)
392+
379393
plugin.restorePurchases { result in
380394
switch result {
381395
case .success():
@@ -385,6 +399,9 @@ final class InAppPurchase2PluginTests: XCTestCase {
385399
}
386400
}
387401
await fulfillment(of: [restoreExpectation], timeout: 5)
402+
403+
XCTAssert(callback.lastUpdate.count == 1)
404+
XCTAssert(callback.lastUpdate.first?.restoring == true)
388405
}
389406

390407
func testFinishTransaction() async throws {

packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: in_app_purchase_storekit
22
description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework.
33
repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22
5-
version: 0.4.7
5+
version: 0.4.8
66

77
environment:
88
sdk: ^3.9.0

packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ void main() {
120120
final List<PurchaseDetails> result = await completer.future;
121121
expect(result.length, 1);
122122
expect(result.first.productID, dummyProductWrapper.id);
123+
expect(result.first.status, PurchaseStatus.purchased);
124+
expect(result.first.pendingCompletePurchase, true);
123125
},
124126
);
125127

0 commit comments

Comments
 (0)