Skip to content

Commit c297f5e

Browse files
m4hmoudclaude
andcommitted
[in_app_purchase_storekit] Fix StoreKit 2 cancel/pending events not sent to purchaseStream
Apply fix from flutter#10736 — StoreKit 2 purchase flow now sends cancelled, pending, and unverified results to purchaseStream instead of silently dropping them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 73dfeaa commit c297f5e

File tree

11 files changed

+468
-227
lines changed

11 files changed

+468
-227
lines changed

packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 0.4.8+1
2+
3+
* Fixes StoreKit 2 purchase flow to send cancelled/pending/unverified results to `purchaseStream`.
14
## 0.4.8
25

36
* Fixes an issue causing StoreKit2 purchases to be reported as `restored` and left in an
@@ -20,7 +23,7 @@
2023
## 0.4.6
2124

2225
* Adds a new case `.unverified` to enum `SK2ProductPurchaseResult`
23-
* Fixes the StoreKit2 implementation throwing `PlatformException`s instead of returning the corresponding
26+
* Fixes the StoreKit2 implementation throwing `PlatformException`s instead of returning the corresponding
2427
`SK2ProductPurchaseResult` when a purchase is cancelled / unverified / pending.
2528

2629
## 0.4.5
@@ -49,7 +52,7 @@
4952

5053
* Updates minimum supported SDK version to Flutter 3.27/Dart 3.6.
5154
* Adds **Win Back Offers** support for StoreKit2:
52-
- Includes new `isWinBackOfferEligible` function for eligibility verification
55+
* Includes new `isWinBackOfferEligible` function for eligibility verification
5356
* Adds **Promotional Offers** support in StoreKit2 purchases
5457
* Fixes introductory pricing handling in promotional offers list in StoreKit2
5558
* Ensures proper `appAccountToken` handling for StoreKit2 purchases

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

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,15 @@ extension InAppPurchasePlugin: InAppPurchase2API {
9090
switch result {
9191
case .success(let verification):
9292
sendTransactionUpdate(
93-
transaction: verification.unsafePayloadValue, receipt: verification.jwsRepresentation)
94-
case .pending, .userCancelled:
95-
break
93+
productId: id,
94+
transaction: verification.unsafePayloadValue,
95+
receipt: verification.jwsRepresentation,
96+
status: .purchased
97+
)
98+
case .pending:
99+
sendTransactionUpdate(productId: id, status: .pending)
100+
case .userCancelled:
101+
sendTransactionUpdate(productId: id, status: .cancelled)
96102
@unknown default:
97103
fatalError("An unknown StoreKit PurchaseResult has been encountered.")
98104
}
@@ -223,7 +229,7 @@ extension InAppPurchasePlugin: InAppPurchase2API {
223229
@MainActor in
224230
do {
225231
let transactionsMsgs = await rawTransactions().map {
226-
$0.convertToPigeon(receipt: nil)
232+
$0.convertToPigeon(receipt: nil, status: .purchased)
227233
}
228234
completion(.success(transactionsMsgs))
229235
}
@@ -242,7 +248,8 @@ extension InAppPurchasePlugin: InAppPurchase2API {
242248
switch verificationResult {
243249
case .verified(let transaction):
244250
transactionsMsgs.append(
245-
transaction.convertToPigeon(receipt: verificationResult.jwsRepresentation)
251+
transaction.convertToPigeon(
252+
receipt: verificationResult.jwsRepresentation, status: .purchased)
246253
)
247254
case .unverified:
248255
break
@@ -261,8 +268,11 @@ extension InAppPurchasePlugin: InAppPurchase2API {
261268
switch completedPurchase {
262269
case .verified(let purchase):
263270
self.sendTransactionUpdate(
264-
transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)",
265-
restoring: true)
271+
productId: purchase.productID,
272+
transaction: purchase,
273+
receipt: "\(completedPurchase.jwsRepresentation)",
274+
status: .restored
275+
)
266276
case .unverified(let failedPurchase, let error):
267277
unverifiedPurchases[failedPurchase.id] = (
268278
receipt: completedPurchase.jwsRepresentation, error: error
@@ -341,7 +351,11 @@ extension InAppPurchasePlugin: InAppPurchase2API {
341351
switch verificationResult {
342352
case .verified(let transaction):
343353
self?.sendTransactionUpdate(
344-
transaction: transaction, receipt: verificationResult.jwsRepresentation)
354+
productId: transaction.productID,
355+
transaction: transaction,
356+
receipt: verificationResult.jwsRepresentation,
357+
status: .purchased
358+
)
345359
case .unverified:
346360
break
347361
}
@@ -354,18 +368,41 @@ extension InAppPurchasePlugin: InAppPurchase2API {
354368
updateListenerTask.cancel()
355369
}
356370

357-
/// Sends an transaction back to Dart. Access these transactions with `purchaseStream`
371+
/// Sends a transaction or status update back to Dart. Access these transactions with `purchaseStream`
372+
/// - Parameters:
373+
/// - productId: The product ID (required)
374+
/// - transaction: The transaction object (for success cases, nil for pending/cancelled)
375+
/// - receipt: The JWS receipt data
376+
/// - status: The purchase status
358377
private func sendTransactionUpdate(
359-
transaction: Transaction, receipt: String? = nil, restoring: Bool = false
378+
productId: String,
379+
transaction: Transaction? = nil,
380+
receipt: String? = nil,
381+
status: SK2PurchaseStatusMessage
360382
) {
361-
let transactionMessage = transaction.convertToPigeon(receipt: receipt, restoring: restoring)
383+
let transactionMessage: SK2TransactionMessage
384+
385+
if let transaction = transaction {
386+
// Has real transaction: use transaction info
387+
transactionMessage = transaction.convertToPigeon(receipt: receipt, status: status)
388+
} else {
389+
// No transaction (pending/cancelled): create minimal message without purchaseDate
390+
transactionMessage = SK2TransactionMessage(
391+
id: 0,
392+
originalId: 0,
393+
productId: productId,
394+
purchasedQuantity: 1,
395+
status: status
396+
)
397+
}
398+
362399
Task { @MainActor in
363400
self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) {
364401
result in
365402
switch result {
366403
case .success: break
367404
case .failure(let error):
368-
print("Failed to send transaction updates: \(error)")
405+
print("Failed to send transaction update: \(error)")
369406
}
370407
}
371408
}

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v26.1.1), do not edit directly.
4+
// Autogenerated from Pigeon (v26.1.5), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66

77
import Foundation
@@ -172,6 +172,19 @@ enum SK2ProductPurchaseResultMessage: Int {
172172
case pending = 3
173173
}
174174

175+
/// The status of a purchase transaction.
176+
/// Used to communicate the result state to Dart layer via purchaseStream.
177+
enum SK2PurchaseStatusMessage: Int {
178+
/// Purchase completed successfully.
179+
case purchased = 0
180+
/// Purchase is pending (e.g., Ask to Buy).
181+
case pending = 1
182+
/// Purchase was cancelled by the user.
183+
case cancelled = 2
184+
/// Purchase was restored.
185+
case restored = 3
186+
}
187+
175188
/// Generated class from Pigeon that represents data sent in messages.
176189
struct SK2SubscriptionOfferMessage: Hashable {
177190
var id: String? = nil
@@ -494,28 +507,30 @@ struct SK2TransactionMessage: Hashable {
494507
var id: Int64
495508
var originalId: Int64
496509
var productId: String
497-
var purchaseDate: String
510+
var purchaseDate: String? = nil
498511
var expirationDate: String? = nil
499512
var purchasedQuantity: Int64
500513
var appAccountToken: String? = nil
501-
var restoring: Bool
502514
var receiptData: String? = nil
503515
var error: SK2ErrorMessage? = nil
504516
var jsonRepresentation: String? = nil
517+
/// The status of this purchase transaction.
518+
/// Set by native side to communicate the result state to Dart layer.
519+
var status: SK2PurchaseStatusMessage
505520

506521
// swift-format-ignore: AlwaysUseLowerCamelCase
507522
static func fromList(_ pigeonVar_list: [Any?]) -> SK2TransactionMessage? {
508523
let id = pigeonVar_list[0] as! Int64
509524
let originalId = pigeonVar_list[1] as! Int64
510525
let productId = pigeonVar_list[2] as! String
511-
let purchaseDate = pigeonVar_list[3] as! String
526+
let purchaseDate: String? = nilOrValue(pigeonVar_list[3])
512527
let expirationDate: String? = nilOrValue(pigeonVar_list[4])
513528
let purchasedQuantity = pigeonVar_list[5] as! Int64
514529
let appAccountToken: String? = nilOrValue(pigeonVar_list[6])
515-
let restoring = pigeonVar_list[7] as! Bool
516-
let receiptData: String? = nilOrValue(pigeonVar_list[8])
517-
let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[9])
518-
let jsonRepresentation: String? = nilOrValue(pigeonVar_list[10])
530+
let receiptData: String? = nilOrValue(pigeonVar_list[7])
531+
let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[8])
532+
let jsonRepresentation: String? = nilOrValue(pigeonVar_list[9])
533+
let status = pigeonVar_list[10] as! SK2PurchaseStatusMessage
519534

520535
return SK2TransactionMessage(
521536
id: id,
@@ -525,10 +540,10 @@ struct SK2TransactionMessage: Hashable {
525540
expirationDate: expirationDate,
526541
purchasedQuantity: purchasedQuantity,
527542
appAccountToken: appAccountToken,
528-
restoring: restoring,
529543
receiptData: receiptData,
530544
error: error,
531-
jsonRepresentation: jsonRepresentation
545+
jsonRepresentation: jsonRepresentation,
546+
status: status
532547
)
533548
}
534549
func toList() -> [Any?] {
@@ -540,10 +555,10 @@ struct SK2TransactionMessage: Hashable {
540555
expirationDate,
541556
purchasedQuantity,
542557
appAccountToken,
543-
restoring,
544558
receiptData,
545559
error,
546560
jsonRepresentation,
561+
status,
547562
]
548563
}
549564
static func == (lhs: SK2TransactionMessage, rhs: SK2TransactionMessage) -> Bool {
@@ -621,24 +636,30 @@ private class StoreKit2MessagesPigeonCodecReader: FlutterStandardReader {
621636
}
622637
return nil
623638
case 134:
624-
return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?])
639+
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
640+
if let enumResultAsInt = enumResultAsInt {
641+
return SK2PurchaseStatusMessage(rawValue: enumResultAsInt)
642+
}
643+
return nil
625644
case 135:
626-
return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?])
645+
return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?])
627646
case 136:
628-
return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?])
647+
return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?])
629648
case 137:
630-
return SK2ProductMessage.fromList(self.readValue() as! [Any?])
649+
return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?])
631650
case 138:
632-
return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?])
651+
return SK2ProductMessage.fromList(self.readValue() as! [Any?])
633652
case 139:
634-
return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?])
653+
return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?])
635654
case 140:
636-
return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?])
655+
return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?])
637656
case 141:
638-
return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?])
657+
return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?])
639658
case 142:
640-
return SK2TransactionMessage.fromList(self.readValue() as! [Any?])
659+
return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?])
641660
case 143:
661+
return SK2TransactionMessage.fromList(self.readValue() as! [Any?])
662+
case 144:
642663
return SK2ErrorMessage.fromList(self.readValue() as! [Any?])
643664
default:
644665
return super.readValue(ofType: type)
@@ -663,35 +684,38 @@ private class StoreKit2MessagesPigeonCodecWriter: FlutterStandardWriter {
663684
} else if let value = value as? SK2ProductPurchaseResultMessage {
664685
super.writeByte(133)
665686
super.writeValue(value.rawValue)
666-
} else if let value = value as? SK2SubscriptionOfferMessage {
687+
} else if let value = value as? SK2PurchaseStatusMessage {
667688
super.writeByte(134)
689+
super.writeValue(value.rawValue)
690+
} else if let value = value as? SK2SubscriptionOfferMessage {
691+
super.writeByte(135)
668692
super.writeValue(value.toList())
669693
} else if let value = value as? SK2SubscriptionPeriodMessage {
670-
super.writeByte(135)
694+
super.writeByte(136)
671695
super.writeValue(value.toList())
672696
} else if let value = value as? SK2SubscriptionInfoMessage {
673-
super.writeByte(136)
697+
super.writeByte(137)
674698
super.writeValue(value.toList())
675699
} else if let value = value as? SK2ProductMessage {
676-
super.writeByte(137)
700+
super.writeByte(138)
677701
super.writeValue(value.toList())
678702
} else if let value = value as? SK2PriceLocaleMessage {
679-
super.writeByte(138)
703+
super.writeByte(139)
680704
super.writeValue(value.toList())
681705
} else if let value = value as? SK2SubscriptionOfferSignatureMessage {
682-
super.writeByte(139)
706+
super.writeByte(140)
683707
super.writeValue(value.toList())
684708
} else if let value = value as? SK2SubscriptionOfferPurchaseMessage {
685-
super.writeByte(140)
709+
super.writeByte(141)
686710
super.writeValue(value.toList())
687711
} else if let value = value as? SK2ProductPurchaseOptionsMessage {
688-
super.writeByte(141)
712+
super.writeByte(142)
689713
super.writeValue(value.toList())
690714
} else if let value = value as? SK2TransactionMessage {
691-
super.writeByte(142)
715+
super.writeByte(143)
692716
super.writeValue(value.toList())
693717
} else if let value = value as? SK2ErrorMessage {
694-
super.writeByte(143)
718+
super.writeByte(144)
695719
super.writeValue(value.toList())
696720
} else {
697721
super.writeValue(value)

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

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

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

197198
let dateFormatter = DateFormatter()
198199
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
@@ -205,9 +206,9 @@ extension Transaction {
205206
expirationDate: expirationDate.map { dateFormatter.string(from: $0) },
206207
purchasedQuantity: Int64(purchasedQuantity),
207208
appAccountToken: appAccountToken?.uuidString,
208-
restoring: restoring,
209209
receiptData: receipt,
210-
jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self)
210+
jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self),
211+
status: status
211212
)
212213
}
213214
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,15 @@ final class InAppPurchase2PluginTests: XCTestCase {
9393

9494
XCTAssert(callback.lastUpdate.count == 1)
9595
XCTAssert(
96-
callback.lastUpdate.first?.restoring == false,
96+
callback.lastUpdate.first?.status != .restored,
9797
"Ordinary purchase updates should not be marked as restoring")
9898

9999
plugin.transactions {
100100
result in
101101
switch result {
102102
case .success(let transactions):
103103
XCTAssert(transactions.count == 1)
104-
XCTAssert(transactions.first?.restoring == false)
104+
XCTAssert(transactions.first?.status != .restored)
105105
transactionExpectation.fulfill()
106106
case .failure(let error):
107107
XCTFail("Getting transactions should NOT fail. Failed with \(error)")
@@ -388,7 +388,7 @@ final class InAppPurchase2PluginTests: XCTestCase {
388388
await fulfillment(of: [purchaseExpectation], timeout: 5)
389389

390390
XCTAssert(callback.lastUpdate.count == 1)
391-
XCTAssert(callback.lastUpdate.first?.restoring == false)
391+
XCTAssert(callback.lastUpdate.first?.status != .restored)
392392

393393
plugin.restorePurchases { result in
394394
switch result {
@@ -401,7 +401,7 @@ final class InAppPurchase2PluginTests: XCTestCase {
401401
await fulfillment(of: [restoreExpectation], timeout: 5)
402402

403403
XCTAssert(callback.lastUpdate.count == 1)
404-
XCTAssert(callback.lastUpdate.first?.restoring == true)
404+
XCTAssert(callback.lastUpdate.first?.status == .restored)
405405
}
406406

407407
func testFinishTransaction() async throws {

0 commit comments

Comments
 (0)