Skip to content

Commit 2203d08

Browse files
authored
[in_app_purchase_storekit] Add support for purchase and transactions (#7574)
This PR contains the preliminary implementations for handling [purchases](https://developer.apple.com/documentation/storekit/product/3791971-purchase) and [transactions](https://developer.apple.com/documentation/storekit/transaction) Of note, as of StoreKit 2, the [result of a call to a purchase will only return a Transaction](https://developer.apple.com/documentation/storekit/product/purchaseresult) when the purchase is successful. If the status of purchase is pending, or cancelled, nothing will be returned. This is in contrast to StoreKit 1, where developers could check the state of a Transaction with [TransactionState](https://developer.apple.com/documentation/storekit/skpaymenttransaction/1411275-transactionstate). This means that migrating to StoreKit 2 will require developers to update their handling of transactions accordingly.
1 parent b6b7bfa commit 2203d08

26 files changed

+1876
-248
lines changed

packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.3.18+2
2+
3+
* Adds support for StoreKit2's `purchase` and `transactions`
4+
15
## 0.3.18+1
26

37
* Adds support for StoreKit2's `canMakePayments` and `products`

packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/InAppPurchasePlugin.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ public class InAppPurchasePlugin: NSObject, FlutterPlugin, InAppPurchaseAPI {
2727
// This property is optional, as it requires self to exist to be initialized.
2828
public var paymentQueueHandler: FLTPaymentQueueHandlerProtocol?
2929

30+
// This should be an Task, but Task is on available >= iOS 13
31+
private var _updateListenerTask: Any?
32+
33+
@available(iOS 13.0, *)
34+
var getListenerTaskAsTask: Task<(), Never> {
35+
return self._updateListenerTask as! Task<(), Never>
36+
}
37+
38+
@available(iOS 13.0, *)
39+
func setListenerTaskAsTask(task: Task<(), Never>) {
40+
self._updateListenerTask = task
41+
}
42+
43+
var transactionCallbackAPI: InAppPurchase2CallbackAPI? = nil
44+
3045
public static func register(with registrar: FlutterPluginRegistrar) {
3146
#if os(iOS)
3247
let messenger = registrar.messenger()
@@ -93,6 +108,7 @@ public class InAppPurchasePlugin: NSObject, FlutterPlugin, InAppPurchaseAPI {
93108
let messenger = registrar.messenger
94109
#endif
95110
setupTransactionObserverChannelIfNeeded(withMessenger: messenger)
111+
self.transactionCallbackAPI = InAppPurchase2CallbackAPI(binaryMessenger: messenger)
96112
}
97113

98114
// MARK: - Pigeon Functions

packages/in_app_purchase/in_app_purchase_storekit/darwin/Classes/StoreKit2/InAppPurchaseStoreKit2.swift

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44

55
@available(iOS 15.0, macOS 12.0, *)
66
extension InAppPurchasePlugin: InAppPurchase2API {
7+
78
// MARK: - Pigeon Functions
89

9-
// Wrapper method around StoreKit2's canMakePayments() method
10-
// https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments
10+
/// Wrapper method around StoreKit2's canMakePayments() method
11+
/// https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments
1112
func canMakePayments() throws -> Bool {
1213
return AppStore.canMakePayments
1314
}
1415

15-
// Wrapper method around StoreKit2's products() method
16-
// https://developer.apple.com/documentation/storekit/product/3851116-products
16+
/// Wrapper method around StoreKit2's products() method
17+
/// https://developer.apple.com/documentation/storekit/product/3851116-products
1718
func products(
1819
identifiers: [String], completion: @escaping (Result<[SK2ProductMessage], Error>) -> Void
1920
) {
@@ -34,4 +35,144 @@ extension InAppPurchasePlugin: InAppPurchase2API {
3435
}
3536
}
3637
}
38+
39+
/// Gets the appropriate product, then calls purchase on it.
40+
/// https://developer.apple.com/documentation/storekit/product/3791971-purchase
41+
func purchase(
42+
id: String, options: SK2ProductPurchaseOptionsMessage?,
43+
completion: @escaping (Result<SK2ProductPurchaseResultMessage, Error>) -> Void
44+
) {
45+
Task { @MainActor in
46+
do {
47+
guard let product = try await Product.products(for: [id]).first else {
48+
let error = PigeonError(
49+
code: "storekit2_failed_to_fetch_product",
50+
message: "Storekit has failed to fetch this product.",
51+
details: "Product ID : \(id)")
52+
return completion(.failure(error))
53+
}
54+
55+
let result = try await product.purchase(options: [])
56+
57+
switch result {
58+
case .success(let verification):
59+
switch verification {
60+
case .verified(let transaction):
61+
self.sendTransactionUpdate(transaction: transaction)
62+
completion(.success(result.convertToPigeon()))
63+
case .unverified(_, let error):
64+
completion(.failure(error))
65+
}
66+
case .pending:
67+
completion(
68+
.failure(
69+
PigeonError(
70+
code: "storekit2_purchase_pending",
71+
message:
72+
"This transaction is still pending and but may complete in the future. If it completes, it will be delivered via `purchaseStream`",
73+
details: "Product ID : \(id)")))
74+
case .userCancelled:
75+
completion(
76+
.failure(
77+
PigeonError(
78+
code: "storekit2_purchase_cancelled",
79+
message: "This transaction has been cancelled by the user.",
80+
details: "Product ID : \(id)")))
81+
@unknown default:
82+
fatalError("An unknown StoreKit PurchaseResult has been encountered.")
83+
}
84+
} catch {
85+
completion(.failure(error))
86+
}
87+
}
88+
}
89+
90+
/// Wrapper method around StoreKit2's transactions() method
91+
/// https://developer.apple.com/documentation/storekit/product/3851116-products
92+
func transactions(
93+
completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void
94+
) {
95+
Task {
96+
@MainActor in
97+
do {
98+
let transactionsMsgs = await rawTransactions().map {
99+
$0.convertToPigeon()
100+
}
101+
completion(.success(transactionsMsgs))
102+
}
103+
}
104+
}
105+
106+
/// Wrapper method around StoreKit2's finish() method https://developer.apple.com/documentation/storekit/transaction/3749694-finish
107+
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void) {
108+
Task {
109+
let transaction = try await fetchTransaction(by: UInt64(id))
110+
if let transaction = transaction {
111+
await transaction.finish()
112+
}
113+
}
114+
}
115+
116+
/// This Task listens to Transation.updates as shown here
117+
/// https://developer.apple.com/documentation/storekit/transaction/3851206-updates
118+
/// This function should be called as soon as the app starts to avoid missing any Transactions done outside of the app.
119+
func startListeningToTransactions() throws {
120+
self.setListenerTaskAsTask(
121+
task: Task { [weak self] in
122+
for await verificationResult in Transaction.updates {
123+
switch verificationResult {
124+
case .verified(let transaction):
125+
self?.sendTransactionUpdate(transaction: transaction)
126+
case .unverified:
127+
break
128+
}
129+
}
130+
})
131+
}
132+
133+
/// Stop subscribing to Transaction.updates
134+
func stopListeningToTransactions() throws {
135+
getListenerTaskAsTask.cancel()
136+
}
137+
138+
/// Sends an transaction back to Dart. Access these transactions with `purchaseStream`
139+
func sendTransactionUpdate(transaction: Transaction) {
140+
let transactionMessage = transaction.convertToPigeon()
141+
transactionCallbackAPI?.onTransactionsUpdated(newTransaction: transactionMessage) { result in
142+
switch result {
143+
case .success: break
144+
case .failure(let error):
145+
print("Failed to send transaction updates: \(error)")
146+
}
147+
}
148+
}
149+
150+
/// Helper function that fetches and unwraps all verified transactions
151+
private func rawTransactions() async -> [Transaction] {
152+
var transactions: [Transaction] = []
153+
for await verificationResult in Transaction.all {
154+
switch verificationResult {
155+
case .verified(let transaction):
156+
transactions.append(transaction)
157+
case .unverified:
158+
break
159+
}
160+
}
161+
return transactions
162+
}
163+
164+
/// Helper function to fetch specific transaction
165+
private func fetchTransaction(by id: UInt64) async throws -> Transaction? {
166+
for await result in Transaction.all {
167+
switch result {
168+
case .verified(let transaction):
169+
if transaction.id == id {
170+
return transaction
171+
}
172+
case .unverified:
173+
continue
174+
}
175+
}
176+
return nil
177+
}
37178
}

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,38 @@ extension SK2PriceLocaleMessage: Equatable {
167167
return lhs.currencyCode == rhs.currencyCode && lhs.currencySymbol == rhs.currencySymbol
168168
}
169169
}
170+
171+
@available(iOS 15.0, macOS 12.0, *)
172+
extension Product.PurchaseResult {
173+
func convertToPigeon() -> SK2ProductPurchaseResultMessage {
174+
switch self {
175+
case .success(_):
176+
return SK2ProductPurchaseResultMessage.success
177+
case .userCancelled:
178+
return SK2ProductPurchaseResultMessage.userCancelled
179+
case .pending:
180+
return SK2ProductPurchaseResultMessage.pending
181+
@unknown default:
182+
fatalError()
183+
}
184+
}
185+
}
186+
187+
@available(iOS 15.0, macOS 12.0, *)
188+
extension Transaction {
189+
func convertToPigeon(restoring: Bool = false) -> SK2TransactionMessage {
190+
191+
let dateFromatter: DateFormatter = DateFormatter()
192+
dateFromatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
193+
194+
return SK2TransactionMessage(
195+
id: Int64(id),
196+
originalId: Int64(originalID),
197+
productId: productID,
198+
purchaseDate: dateFromatter.string(from: purchaseDate),
199+
purchasedQuantity: Int64(purchasedQuantity),
200+
appAccountToken: appAccountToken?.uuidString,
201+
restoring: restoring
202+
)
203+
}
204+
}

0 commit comments

Comments
 (0)