/
StoreHelper.swift
685 lines (542 loc) · 36.4 KB
/
StoreHelper.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
//
// StoreHelper.swift
// StoreHelper
//
// Created by Russell Archer on 16/06/2021.
//
import StoreKit
import Collections
public typealias ProductId = String
/// The state of a purchase.
public enum PurchaseState { case notStarted, userCannotMakePayments, inProgress, purchased, pending, cancelled, failed, failedVerification, unknown }
/// Information on the result of unwrapping a transaction `VerificationResult`.
@available(iOS 15.0, macOS 12.0, *)
public struct UnwrappedVerificationResult<T> {
/// The verified or unverified transaction.
public let transaction: T
/// True if the transaction was successfully verified by StoreKit.
public let verified: Bool
/// If `verified` is false then `verificationError` will hold the verification error, nil otherwise.
public let verificationError: VerificationResult<T>.VerificationError?
}
/// StoreHelper encapsulates StoreKit2 in-app purchase functionality and makes it easy to work with the App Store.
@available(iOS 15.0, macOS 12.0, *)
public class StoreHelper: ObservableObject {
// MARK: - Public properties
/// Array of `Product` retrieved from the App Store and available for purchase.
@Published public private(set) var products: [Product]?
/// Array of `ProductId` for products that have been purchased. Each purchased non-consumable product will appear
/// exactly once. Consumable products can appear more than once.
///
/// This array is primarily used to trigger updates in the UI. It is not persisted but re-built as required
/// whenever a purchase successfully completes, or when a call is made to `isPurchased(product:)`.
///
/// - Call `isPurchased(product:)` to see if any type of product has been purchased and validated against the receipt.
/// - Call `StoreHelper.count(for:)` to see how many times a consumable product has been purchased.
@Published public private(set) var purchasedProducts = [ProductId]()
/// List of purchased product ids. This list is used as a fallback when the App Store in unavailable.
/// The public `isPurchased(product:)` method will use this list when `products` is nil and `isAppStoreAvailable` is false.
/// This collection is updated with live App Store data when calls are made to `isPurchased(product:)`.
public var purchasedProductsFallback = [ProductId]()
/// Set to true if we successfully retrieve a list of available products from the App Store.
public private(set) var isAppStoreAvailable = false
/// `OrderedSet` of `ProductId` that have been read from the Product.plist configuration file. The order in which
/// product ids are defined in the property list file is maintained in the set.
public private(set) var productIds: OrderedSet<ProductId>?
/// Subscription-related helper methods.
public var subscriptionHelper: SubscriptionHelper!
/// True if StoreHelper has been initialized correctly by calling start().
public var hasStarted: Bool { transactionListener != nil && isAppStoreAvailable }
/// Optional support for overriding dynamic font size.
public var fontScaleFactor: Double {
get { _fontScaleFactor ?? FontUtil.baseDynamicTypeSize(for: .large)}
set { _fontScaleFactor = newValue }
}
/// Set to true if we're currently waiting for a refreshed list of localized products from the App Store.
public private(set) var isRefreshingProducts = false
/// Optional dictionary of configuration values that may be used to override StoreHelper defaults.
/// Add a property list file to your project to override StoreHelper defaults.
/// See `https://github.com/russell-archer/StoreHelperDemo` Configuration.plist for an example.
public private(set) var configurationOverride: [String : AnyObject]?
// MARK: - Public helper properties
public var consumableProducts: [Product]? { products?.filter { $0.type == .consumable }}
public var nonConsumableProducts: [Product]? { products?.filter { $0.type == .nonConsumable }}
public var subscriptionProducts: [Product]? { products?.filter { $0.type == .autoRenewable }}
public var nonSubscriptionProducts: [Product]? { products?.filter { $0.type == .nonRenewable }}
public var consumableProductIds: [ProductId]? { products?.filter { $0.type == .consumable }.map { $0.id }}
public var nonConsumableProductIds: [ProductId]? { products?.filter { $0.type == .nonConsumable }.map { $0.id }}
public var subscriptionProductIds: [ProductId]? { products?.filter { $0.type == .autoRenewable }.map { $0.id }}
public var nonSubscriptionProductIds: [ProductId]? { products?.filter { $0.type == .nonRenewable }.map { $0.id }}
public var hasProducts: Bool { products?.count ?? 0 > 0 ? true : false }
public var hasConsumableProducts: Bool { consumableProducts?.count ?? 0 > 0 ? true : false }
public var hasNonConsumableProducts: Bool { nonConsumableProducts?.count ?? 0 > 0 ? true : false }
public var hasSubscriptionProducts: Bool { subscriptionProducts?.count ?? 0 > 0 ? true : false }
public var hasNonSubscriptionProducts: Bool { nonSubscriptionProducts?.count ?? 0 > 0 ? true : false }
public var hasConsumableProductIds: Bool { consumableProductIds?.count ?? 0 > 0 ? true : false }
public var hasNonConsumableProductIds: Bool { nonConsumableProductIds?.count ?? 0 > 0 ? true : false }
public var hasSubscriptionProductIds: Bool { subscriptionProductIds?.count ?? 0 > 0 ? true : false }
public var hasNonSubscriptionProductIds: Bool { nonSubscriptionProducts?.count ?? 0 > 0 ? true : false }
// MARK: - Private properties
/// Handle for App Store transactions.
private var transactionListener: Task<Void, Error>? = nil
/// The current internal state of StoreHelper. If `purchaseState == inProgress` then an attempt to start
/// a new purchase will result in a `purchaseInProgressException` being thrown by `purchase(_:)`.
private var purchaseState: PurchaseState = .unknown
/// Support for App Store IAP promotions and StoreKit1. Only used for purchase of IAPs direct from the App Store.
private var appStoreHelper: AppStoreHelper?
/// Support for overriding dynamic font scale.
private var _fontScaleFactor: Double? = nil
// MARK: - Initialization
/// StoreHelper enables support for working with in-app purchases and StoreKit2 using the async/await pattern.
/// This initializer will start support for direct purchases from the app store (IAP promotions) and read the
/// Products.plist configuration file to get a list of `ProductId` that defines the set of products we'll request
/// from the App Store. Your app must call `StoreHelper.start()` as soon as possible after StoreHelper has
/// been initialized.
public init() {
// Add a helper for StoreKit1-based direct purchases from the app store (IAP promotions)
appStoreHelper = AppStoreHelper(storeHelper: self)
// Initialize our subscription helper
subscriptionHelper = SubscriptionHelper(storeHelper: self)
// Read our list of product ids
productIds = StoreConfiguration.readConfigFile()
// Read the hosts Configuration.plist file that overrides our default values
configurationOverride = readConfigurationOverride()
// Get the fallback list of purchased products in case the App Store's not available
purchasedProductsFallback = readPurchasedProductsFallbackList()
}
deinit { transactionListener?.cancel() }
// MARK: - Public methods
/// Call this method as soon as possible after your app starts and StoreHelper has been initialized.
/// Failure to call` start()` may result in transactions being missed.
/// This method starts listening for App Store transactions and requests localized product info from the App Store.
@MainActor public func start() {
guard !hasStarted else { return }
// Listen for App Store transactions
transactionListener = handleTransactions()
// Get localized product info from the App Store
refreshProductsFromAppStore()
}
/// Request refreshed localized product info from the App Store. In general, use this method
/// in preference to `requestProductsFromAppStore(productIds:)` as you don't need to supply
/// an ordered set of App Store-defined product ids.
/// This method runs on the main thread because it may result in updates to the UI.
@MainActor public func refreshProductsFromAppStore() {
Task.init {
guard let pids = productIds else { return }
isAppStoreAvailable = false
isRefreshingProducts = true
products = await requestProductsFromAppStore(productIds: pids)
}
}
/// Request localized product info from the App Store for a set of ProductId.
///
/// This method runs on the main thread because it may result in updates to the UI.
/// - Parameter productIds: The product ids that you want localized information for.
/// - Returns: Returns an array of `Product`, or nil if no product information is returned by the App Store.
@MainActor public func requestProductsFromAppStore(productIds: OrderedSet<ProductId>) async -> [Product]? {
defer { isRefreshingProducts = false }
StoreLog.event(.requestProductsStarted)
guard let localizedProducts = try? await Product.products(for: productIds) else {
isAppStoreAvailable = false
StoreLog.event(.requestProductsFailure)
return nil
}
isAppStoreAvailable = true
StoreLog.event(.requestProductsSuccess)
return localizedProducts
}
/// Requests the most recent transaction for a product from the App Store and determines if it has been previously purchased.
///
/// May throw an exception of type `StoreException.transactionVerificationFailed`.
/// - Parameter productId: The `ProductId` of the product.
/// - Returns: Returns true if the product has been purchased, false otherwise.
@MainActor public func isPurchased(productId: ProductId) async throws -> Bool {
var purchased = false
guard hasStarted else {
StoreLog.event("Please call StoreHelper.start() before use.")
return false
}
guard isAppStoreAvailable, hasProducts else {
// The App Store is not available, or it didn't return a list of localized products
// so we use the temporary fallback list of purchased products
return purchasedProductsFallback.contains(productId)
}
guard let product = product(from: productId) else {
updatePurchasedProductsFallbackList(for: productId, purchased: false)
AppGroupSupport.syncPurchase(storeHelper: self, productId: productId, purchased: false)
return false
}
// We need to treat consumables differently because their transactions are NOT stored in the receipt.
if product.type == .consumable {
purchased = KeychainHelper.count(for: productId) > 0
await updatePurchasedIdentifiers(productId, insert: purchased)
updatePurchasedProductsFallbackList(for: productId, purchased: purchased)
AppGroupSupport.syncPurchase(storeHelper: self, productId: productId, purchased: purchased)
return purchased
}
guard let currentEntitlement = await Transaction.currentEntitlement(for: productId) else {
// There's no transaction for the product, so it hasn't been purchased
AppGroupSupport.syncPurchase(storeHelper: self, productId: productId, purchased: false)
updatePurchasedProductsFallbackList(for: productId, purchased: false)
return false
}
// See if the transaction passed StoreKit's automatic verification
let result = checkVerificationResult(result: currentEntitlement)
if !result.verified {
StoreLog.transaction(.transactionValidationFailure, productId: result.transaction.productID)
throw StoreException.transactionVerificationFailed
}
// Make sure our internal set of purchase pids is in-sync with the App Store
await updatePurchasedIdentifiers(result.transaction)
// See if the App Store has revoked the user's access to the product (e.g. because of a refund).
// If this transaction represents a subscription, see if the user upgraded to a higher-level subscription.
purchased = result.transaction.revocationDate == nil && !result.transaction.isUpgraded
// Update UserDefaults in the container shared between ourselves and other members of the group.com.{developer}.{appname} AppGroup.
// Currently this is done so that widgets can tell what IAPs have been purchased. Note that widgets can't use StoreHelper directly
// because the they don't purchase anything and are not considered to be part of the app that did the purchasing as far as
// StoreKit is concerned.
AppGroupSupport.syncPurchase(storeHelper: self, productId: product.id, purchased: purchased)
// Update and persist our fallback list of purchased products
updatePurchasedProductsFallbackList(for: productId, purchased: purchased)
return purchased
}
/// Requests the most recent transaction for a product from the App Store and determines if it has been previously purchased.
///
/// May throw an exception of type `StoreException.transactionVerificationFailed`.
/// - Parameter productId: The `ProductId` of the product.
/// - Returns: Returns true if the product has been purchased, false otherwise.
@MainActor public func isPurchased(product: Product) async throws -> Bool { try await isPurchased(productId: product.id) }
/// Determines if a product is currently subscribed to.
///
/// May throw an exception of type `StoreException.transactionVerificationFailed`.
/// - Parameter product: The `Product` representing the subscription product.
/// - Returns: Returns true if the product is currently subscribed to, false otherwise.
@MainActor public func isSubscribed(product: Product) async throws -> Bool { try await isPurchased(productId: product.id) }
/// Determines if the product is currently subscribed to.
///
/// May throw an exception of type `StoreException.transactionVerificationFailed`.
/// - Parameter productId: The `ProductId` of the subscription product.
/// - Returns: Returns true if the product is currently subscribed to, false otherwise.
@MainActor public func isSubscribed(productId: ProductId) async throws -> Bool { try await isPurchased(productId: productId) }
/// Returns true if the product uniquely identified by `productId` is a subscription.
/// - Parameter productId: `ProductId` that uniquely identifies a product available in the App Store.
/// - Returns: Returns true if the product uniquely identified by `productId` is a subscription, false otherwise.
public func isSubscription(productId: ProductId) -> Bool { subscriptionProductIds == nil ? false : subscriptionProductIds!.contains { pid in pid == productId }}
/// Returns true if the product is a subscription.
/// - Parameter product: `Product` that uniquely identifies a product available in the App Store.
/// - Returns: Returns true if the product is a subscription, false otherwise.
public func isSubscription(product: Product) -> Bool { return isSubscription(productId: product.id) }
/// Returns true if the product uniquely identified by `productId` is a consumable.
/// - Parameter productId: `ProductId` that uniquely identifies a product available in the App Store.
/// - Returns: Returns true if the product uniquely identified by `productId` is a consumable, false otherwise.
public func isConsumable(productId: ProductId) -> Bool { consumableProductIds == nil ? false : consumableProductIds!.contains { pid in pid == productId }}
/// Returns true if the product is a consumable.
/// - Parameter product: `Product` that uniquely identifies a product available in the App Store.
/// - Returns: Returns true if the product is a consumable, false otherwise.
public func isConsumable(product: Product) -> Bool { return isConsumable(productId: product.id) }
/// Returns true if the product uniquely identified by `productId` is a non-consumable.
/// - Parameter productId: `ProductId` that uniquely identifies a product available in the App Store.
/// - Returns: Returns true if the product uniquely identified by `productId` is a non-consumable, false otherwise.
public func isNonConsumable(productId: ProductId) -> Bool { nonConsumableProductIds == nil ? false : nonConsumableProductIds!.contains { pid in pid == productId }}
/// Returns true if the product is a non-consumable.
/// - Parameter product: `Product` that uniquely identifies a product available in the App Store.
/// - Returns: Returns true if the product is a non-consumable, false otherwise.
public func isNonConsumable(product: Product) -> Bool { return isNonConsumable(productId: product.id) }
/// Uses StoreKit's `Transaction.currentEntitlements` property to iterate over the sequence of `VerificationResult<Transaction>`
/// representing all transactions for products the user is currently entitled to. That is, all currently-subscribed
/// transactions and all purchased (and not refunded) non-consumables. Note that transactions for consumables are NOT
/// in the receipt.
/// - Returns: A verified `Set<ProductId>` for all products the user is entitled to have access to. The set will be empty if the
/// user has not purchased anything previously.
@MainActor public func currentEntitlements() async -> Set<ProductId> {
var entitledProductIds = Set<ProductId>()
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
entitledProductIds.insert(transaction.productID) // Ignore unverified transactions
}
}
return entitledProductIds
}
/// Purchase a `Product` previously returned from the App Store following a call to `requestProductsFromAppStore()`.
///
/// May throw an exception of type:
/// - `StoreException.purchaseException` if the App Store itself throws an exception
/// - `StoreException.purchaseInProgressException` if a purchase is already in progress
/// - `StoreException.transactionVerificationFailed` if the purchase transaction failed verification
///
/// - Parameter product: The `Product` to purchase.
/// - Parameter options: Purchase options. See Product.PurchaseOption.
/// - Returns: Returns a tuple consisting of a transaction object that represents the purchase and a `PurchaseState`
/// describing the state of the purchase.
@MainActor public func purchase(_ product: Product, options: Set<Product.PurchaseOption> = []) async throws -> (transaction: Transaction?, purchaseState: PurchaseState) {
guard hasStarted else {
StoreLog.event("Please call StoreHelper.start() before use.")
return (nil, .notStarted)
}
guard AppStore.canMakePayments else {
StoreLog.event(.purchaseUserCannotMakePayments)
return (nil, .userCannotMakePayments)
}
guard purchaseState != .inProgress else {
StoreLog.exception(.purchaseInProgressException, productId: product.id)
throw StoreException.purchaseInProgressException
}
// Start a purchase transaction
purchaseState = .inProgress
StoreLog.event(.purchaseInProgress, productId: product.id)
guard let result = try? await product.purchase(options: options) else {
purchaseState = .failed
StoreLog.event(.purchaseFailure, productId: product.id)
throw StoreException.purchaseException
}
// Every time an app receives a transaction from StoreKit 2, the transaction has already passed through a
// verification process to confirm whether the payload is signed by the App Store for my app for this device.
// That is, Storekit2 does transaction (receipt) verification for you (no more OpenSSL or needing to send
// a receipt to an Apple server for verification).
// We now have a PurchaseResult value. See if the purchase suceeded, failed, was cancelled or is pending.
switch result {
case .success(let verificationResult):
// The purchase seems to have succeeded. StoreKit has already automatically attempted to validate
// the transaction, returning the result of this validation wrapped in a `VerificationResult`.
// We now need to check the `VerificationResult<Transaction>` to see if the transaction passed the
// App Store's validation process. This is equivalent to receipt validation in StoreKit1.
// Did the transaction pass StoreKit’s automatic validation?
let checkResult = checkVerificationResult(result: verificationResult)
if !checkResult.verified {
purchaseState = .failedVerification
StoreLog.transaction(.transactionValidationFailure, productId: checkResult.transaction.productID)
throw StoreException.transactionVerificationFailed
}
// The transaction was successfully validated.
let validatedTransaction = checkResult.transaction
// Update the list of purchased ids. Because it's is a @Published var this will cause the UI
// showing the list of products to update
await updatePurchasedIdentifiers(validatedTransaction)
// Tell the App Store we delivered the purchased content to the user
await validatedTransaction.finish()
// Let the caller know the purchase succeeded and that the user should be given access to the product
purchaseState = .purchased
StoreLog.event(.purchaseSuccess, productId: product.id)
if validatedTransaction.productType == .consumable {
// We need to treat consumables differently because their transactions are NOT stored in the receipt.
if KeychainHelper.purchase(product.id) { await updatePurchasedIdentifiers(product.id, insert: true) }
else { StoreLog.event(.consumableKeychainError) }
}
// Update UserDefaults in the container shared between ourselves and other members of the group.com.{developer}.{appname} AppGroup.
// Currently this is done so that widgets can tell what IAPs have been purchased. Note that widgets can't use StoreHelper directly
// because the they don't purchase anything and are not considered to be part of the app that did the purchasing as far as
// StoreKit is concerned.
AppGroupSupport.syncPurchase(storeHelper: self, productId: product.id, purchased: true)
return (transaction: validatedTransaction, purchaseState: .purchased)
case .userCancelled:
purchaseState = .cancelled
StoreLog.event(.purchaseCancelled, productId: product.id)
return (transaction: nil, .cancelled)
case .pending:
purchaseState = .pending
StoreLog.event(.purchasePending, productId: product.id)
return (transaction: nil, .pending)
default:
purchaseState = .unknown
StoreLog.event(.purchaseFailure, productId: product.id)
return (transaction: nil, .unknown)
}
}
/// Should be called only when a purchase is handled by the StoreKit1-based AppHelper.
/// This will be as a result of a user dirctly purchasing in IAP in the App Store ("IAP Promotion"), rather than in our app.
/// - Parameter product: The ProductId of the purchased product.
@MainActor public func productPurchased(_ productId: ProductId) {
Task.init { await updatePurchasedIdentifiers(productId, insert: true)}
purchaseState = .purchased
StoreLog.event(.purchaseSuccess, productId: productId)
AppGroupSupport.syncPurchase(storeHelper: self, productId: productId, purchased: true)
}
/// The `Product` associated with a `ProductId`.
/// - Parameter productId: `ProductId`.
/// - Returns: Returns the `Product` associated with a `ProductId`.
public func product(from productId: ProductId) -> Product? {
guard let p = products else { return nil }
let matchingProduct = p.filter { product in
product.id == productId
}
guard matchingProduct.count == 1 else { return nil }
return matchingProduct.first
}
/// Information on a non-consumable product.
/// - Parameter productId: The `ProductId` of the product.
/// - Returns: Information on a non-consumable product.
/// If the product is not non-consumable nil is returned.
@MainActor public func purchaseInfo(for productId: ProductId) async -> PurchaseInfo? {
guard let p = product(from: productId) else { return nil }
return await purchaseInfo(for: p)
}
/// Transaction information for a non-consumable product.
/// - Parameter product: The `Product` you want information on.
/// - Returns: Transaction information on a non-consumable product.
/// If the product is not non-consumable nil is returned.
@MainActor public func purchaseInfo(for product: Product) async -> PurchaseInfo? {
guard product.type == .nonConsumable else { return nil }
var purchaseInfo = PurchaseInfo(product: product)
guard let unverifiedTransaction = await product.latestTransaction else { return nil }
let transactionResult = checkVerificationResult(result: unverifiedTransaction)
guard transactionResult.verified else { return nil }
purchaseInfo.latestVerifiedTransaction = transactionResult.transaction
return purchaseInfo
}
/// Check if StoreKit was able to automatically verify a transaction by inspecting the verification result.
///
/// - Parameter result: The transaction VerificationResult to check.
/// - Returns: Returns an `UnwrappedVerificationResult<T>` where `verified` is true if the transaction was
/// successfully verified by StoreKit. When `verified` is false `verificationError` will be non-nil.
@MainActor public func checkVerificationResult<T>(result: VerificationResult<T>) -> UnwrappedVerificationResult<T> {
switch result {
case .unverified(let unverifiedTransaction, let error):
// StoreKit failed to automatically validate the transaction
return UnwrappedVerificationResult(transaction: unverifiedTransaction, verified: false, verificationError: error)
case .verified(let verifiedTransaction):
// StoreKit successfully automatically validated the transaction
return UnwrappedVerificationResult(transaction: verifiedTransaction, verified: true, verificationError: nil)
}
}
/// Gets the unique transaction id for the product's most recent transaction.
/// - Parameter productId: The product's unique App Store id.
/// - Returns: Returns the unique transaction id for the product's most recent transaction, or nil if the product's never been purchased.
@MainActor public func mostRecentTransactionId(for productId: ProductId) async -> UInt64? {
if let result = await Transaction.latest(for: productId) {
let verificationResult = checkVerificationResult(result: result)
if verificationResult.verified { return verificationResult.transaction.id }
}
return nil
}
/// Gets the most recent transaction for the product.
/// - Parameter productId: The product's unique App Store id.
/// - Returns: Returns the most recent transaction for the product, or nil if the product's never been purchased.
@MainActor public func mostRecentTransaction(for productId: ProductId) async -> Transaction? {
if let result = await Transaction.latest(for: productId) {
let verificationResult = checkVerificationResult(result: result)
if verificationResult.verified { return verificationResult.transaction }
}
return nil
}
// MARK: - Internal methods
/// Update our list of purchased product identifiers (see `purchasedProducts`).
///
/// This method runs on the main thread because it will result in updates to the UI.
/// - Parameter transaction: The `Transaction` that will result in changes to `purchasedProducts`.
@MainActor internal func updatePurchasedIdentifiers(_ transaction: Transaction) async {
var add = true
// Has the user's access to the product has been revoked by the App Store?
if transaction.revocationDate == nil { add = false }
// Has the user's subscription has expired
if let expirationDate = transaction.expirationDate, expirationDate < Date() { add = false }
// The transaction has been superceeded by an active, higher-value subscription
if transaction.isUpgraded { add = false }
// Add or remove the ProductId to/from the list of `purchasedProducts`
await updatePurchasedIdentifiers(transaction.productID, insert: add)
}
/// Update our list of purchased product identifiers (see `purchasedProducts`).
/// - Parameters:
/// - productId: The `ProductId` to insert/remove.
/// - insert: If true the `ProductId` is inserted, otherwise it's removed.
@MainActor internal func updatePurchasedIdentifiers(_ productId: ProductId, insert: Bool) async {
guard let product = product(from: productId) else { return }
if insert {
if product.type == .consumable {
let count = KeychainHelper.count(for: productId)
let products = purchasedProducts.filter({ $0 == productId })
if count == products.count { return }
} else {
if purchasedProducts.contains(productId) { return }
}
purchasedProducts.append(productId)
} else {
if let index = purchasedProducts.firstIndex(where: { $0 == productId}) {
purchasedProducts.remove(at: index)
}
}
}
// MARK: - Private methods
/// This is an infinite async sequence (loop). It will continue waiting for transactions until it is explicitly
/// canceled by calling the Task.cancel() method. See `transactionListener`.
/// - Returns: Returns a task for the transaction handling loop task.
@MainActor private func handleTransactions() -> Task<Void, Error> {
return Task.detached {
for await verificationResult in Transaction.updates {
// See if StoreKit validated the transaction
let checkResult = await self.checkVerificationResult(result: verificationResult)
guard checkResult.verified else {
// StoreKit's attempts to validate the transaction failed
StoreLog.transaction(.transactionFailure, productId: checkResult.transaction.productID)
return
}
// The transaction was validated by StoreKit
let transaction = checkResult.transaction
StoreLog.transaction(.transactionReceived, productId: transaction.productID)
if transaction.revocationDate != nil {
// The user's access to the product has been revoked by the App Store (e.g. a refund, etc.)
// See transaction.revocationReason for more details if required
StoreLog.transaction(.transactionRevoked, productId: transaction.productID)
return
}
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
// The user's subscription has expired
StoreLog.transaction(.transactionExpired, productId: transaction.productID)
return
}
if transaction.isUpgraded {
// Transaction superceeded by an active, higher-value subscription
StoreLog.transaction(.transactionUpgraded, productId: transaction.productID)
return
}
// Update the list of products the user has access to
StoreLog.transaction(.transactionSuccess, productId: transaction.productID)
await self.updatePurchasedIdentifiers(transaction)
await transaction.finish()
}
}
}
/// Read the property list provided by the host app that overrides StoreHelper default values.
/// - Returns: Returns a dictionary of key-value pairs, or nil if the configuration plist file cannot be found.
private func readConfigurationOverride() -> [String : AnyObject]? {
let configurationOverride = PropertyFile.read(filename: StoreConstants.Configuration)
guard configurationOverride != nil else {
StoreLog.event(.configurationOverrideNotFound) // This is not necessarily an error. Overriding our configuration is optional
return nil
}
StoreLog.event(.configurationOverrideSuccess)
return configurationOverride
}
/// Read the list of fallback purchased products from storage.
/// - Returns: Returns the list of fallback product ids, or nil if none is available.
private func readPurchasedProductsFallbackList() -> [ProductId] {
if let collection = UserDefaults.standard.object(forKey: StoreConstants.PurchasedProductsFallbackKey) as? [ProductId] {
return collection
}
return [ProductId]()
}
/// Saves the fallback collection of purchased product ids.
private func savePurchasedProductsFallbackList() {
UserDefaults.standard.set(purchasedProductsFallback, forKey: StoreConstants.PurchasedProductsFallbackKey)
}
/// Add a ProductId from the list of fallback purchased product ids. The list is then persisted to UserDefaults.
/// - Parameter productId: The ProductId to add.
private func addToPurchasedProductsFallbackList(productId: ProductId) {
if purchasedProductsFallback.contains(productId) { return }
purchasedProductsFallback.append(productId)
savePurchasedProductsFallbackList()
}
/// Remove a ProductId from the list of fallback purchased product ids. The list is then persisted to UserDefaults.
/// - Parameter productId: The ProductId to remove.
private func removeFromPurchasedProductsFallbackList(productId: ProductId) {
purchasedProductsFallback.removeAll(where: { $0 == productId })
savePurchasedProductsFallbackList()
}
/// Add or removes the ProductId to/from the list of fallback purchased product ids. The list is then persisted to UserDefaults.
/// - Parameters:
/// - productId: The ProductId to add or remove.
/// - purchased: true if the product was purchased, false otherwise.
private func updatePurchasedProductsFallbackList(for productId: ProductId, purchased: Bool) {
if purchased { addToPurchasedProductsFallbackList(productId: productId)}
else { removeFromPurchasedProductsFallbackList(productId: productId)}
}
}