201 lines
6.5 KiB
Swift
201 lines
6.5 KiB
Swift
/*
|
||
Abstract:
|
||
The store class is responsible for requesting products from the App Store and starting purchases.
|
||
*/
|
||
|
||
import Foundation
|
||
import StoreKit
|
||
|
||
typealias Transaction = StoreKit.Transaction
|
||
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
|
||
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
|
||
|
||
public enum StoreError: Error {
|
||
case failedVerification
|
||
}
|
||
|
||
class Store: ObservableObject {
|
||
|
||
@Published private(set) var tips: [Product]
|
||
|
||
@Published private(set) var purchasedTips: [Product] = []
|
||
|
||
var updateListenerTask: Task<Void, Error>? = nil
|
||
|
||
private let productIdToEmoji: [String: String]
|
||
|
||
init() {
|
||
productIdToEmoji = Store.loadProductIdToEmojiData()
|
||
|
||
//Initialize empty products, and then do a product request asynchronously to fill them in.
|
||
tips = []
|
||
|
||
//Start a transaction listener as close to app launch as possible so you don't miss any transactions.
|
||
updateListenerTask = listenForTransactions()
|
||
|
||
Task {
|
||
//During store initialization, request products from the App Store.
|
||
await requestProducts()
|
||
|
||
//Deliver products that the customer purchases.
|
||
await updateCustomerProductStatus()
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
updateListenerTask?.cancel()
|
||
}
|
||
|
||
static func loadProductIdToEmojiData() -> [String: String] {
|
||
guard let path = Bundle.main.path(forResource: "Products", ofType: "plist"),
|
||
let plist = FileManager.default.contents(atPath: path),
|
||
let data = try? PropertyListSerialization.propertyList(from: plist, format: nil) as? [String: String] else {
|
||
return [:]
|
||
}
|
||
return data
|
||
}
|
||
|
||
func listenForTransactions() -> Task<Void, Error> {
|
||
return Task.detached {
|
||
//Iterate through any transactions that don't come from a direct call to `purchase()`.
|
||
for await result in Transaction.updates {
|
||
do {
|
||
let transaction = try self.checkVerified(result)
|
||
|
||
//Deliver products to the user.
|
||
await self.updateCustomerProductStatus()
|
||
|
||
//Always finish a transaction.
|
||
await transaction.finish()
|
||
} catch {
|
||
//StoreKit has a transaction that fails verification. Don't deliver content to the user.
|
||
print("Transaction failed verification")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func requestProducts() async {
|
||
do {
|
||
//Request products from the App Store using the identifiers that the Products.plist file defines.
|
||
let storeProducts = try await Product.products(for: productIdToEmoji.keys)
|
||
|
||
var newTips: [Product] = []
|
||
|
||
//Filter the products into categories based on their type.
|
||
for product in storeProducts {
|
||
switch product.type {
|
||
case .consumable:
|
||
break
|
||
case .nonConsumable:
|
||
newTips.append(product)
|
||
case .autoRenewable:
|
||
break
|
||
case .nonRenewable:
|
||
break
|
||
default:
|
||
//Ignore this product.
|
||
print("Unknown product")
|
||
}
|
||
}
|
||
|
||
//Sort each product category by price, lowest to highest, to update the store.
|
||
tips = sortByPrice(newTips)
|
||
} catch {
|
||
print("Failed product request from the App Store server: \(error)")
|
||
}
|
||
}
|
||
|
||
func purchase(_ product: Product) async throws -> Transaction? {
|
||
//Begin purchasing the `Product` the user selects.
|
||
let result = try await product.purchase()
|
||
|
||
switch result {
|
||
case .success(let verification):
|
||
//Check whether the transaction is verified. If it isn't,
|
||
//this function rethrows the verification error.
|
||
let transaction = try checkVerified(verification)
|
||
|
||
//The transaction is verified. Deliver content to the user.
|
||
await updateCustomerProductStatus()
|
||
|
||
//Always finish a transaction.
|
||
await transaction.finish()
|
||
|
||
return transaction
|
||
case .userCancelled, .pending:
|
||
return nil
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func isPurchased(_ product: Product) async throws -> Bool {
|
||
//Determine whether the user purchases a given product.
|
||
switch product.type {
|
||
case .nonRenewable:
|
||
break
|
||
case .nonConsumable:
|
||
return purchasedTips.contains(product)
|
||
case .autoRenewable:
|
||
break
|
||
default:
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||
//Check whether the JWS passes StoreKit verification.
|
||
switch result {
|
||
case .unverified:
|
||
//StoreKit parses the JWS, but it fails verification.
|
||
throw StoreError.failedVerification
|
||
case .verified(let safe):
|
||
//The result is verified. Return the unwrapped value.
|
||
return safe
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func updateCustomerProductStatus() async {
|
||
var purchasedTips: [Product] = []
|
||
|
||
//Iterate through all of the user's purchased products.
|
||
for await result in Transaction.currentEntitlements {
|
||
do {
|
||
//Check whether the transaction is verified. If it isn’t, catch `failedVerification` error.
|
||
let transaction = try checkVerified(result)
|
||
|
||
//Check the `productType` of the transaction and get the corresponding product from the store.
|
||
switch transaction.productType {
|
||
case .nonConsumable:
|
||
if let tip = tips.first(where: { $0.id == transaction.productID }) {
|
||
purchasedTips.append(tip)
|
||
}
|
||
case .nonRenewable:
|
||
break
|
||
case .autoRenewable:
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
} catch {
|
||
print()
|
||
}
|
||
}
|
||
|
||
//Update the store information with the purchased products.
|
||
self.purchasedTips = purchasedTips
|
||
}
|
||
|
||
func emoji(for productId: String) -> String {
|
||
return productIdToEmoji[productId]!
|
||
}
|
||
|
||
func sortByPrice(_ products: [Product]) -> [Product] {
|
||
products.sorted(by: { return $0.price < $1.price })
|
||
}
|
||
}
|