/* See LICENSE folder for this sample’s licensing information. Abstract: The store class is responsible for requesting products from the App Store and starting purchases. */ import Foundation import StoreKit import SwiftUI typealias Transaction = StoreKit.Transaction typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState public enum StoreError: Error { case failedVerification } class IAPManager: ObservableObject { @Published private(set) var showIAP = false @Published private(set) var showIAPWarning = false @Published private(set) var isPurchasing = false @Published private(set) var subscriptions = [Product: (status: [Product.SubscriptionInfo.Status], renewalInfo: RenewalInfo)?]() private(set) var purchasedProductIDs = Set() @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() @Published private(set) var isLoadingSubscriptions = false public var sortedSubscriptionKeysByPriceOptions: [Product] { subscriptions.keys.sorted(by: { $0.price < $1.price }) } public var daysLeftBeforeIAP: Int { let daysSinceInstall = Calendar.current.dateComponents([.day, .hour, .minute, .second], from: firstLaunchDate, to: Date()) if let days = daysSinceInstall.day { return 30 - days } return 0 } private var shouldShowIAP: Bool { if shouldShowIAPWarning && daysLeftBeforeIAP <= 0{ return true } return false } private var shouldShowIAPWarning: Bool { // if we have't fetch all subscriptions yet use faster // purchasedProductIDs if subscriptions.isEmpty { if purchasedProductIDs.isEmpty { return true } else { return false } } else { if currentSubscription == nil { return true } return false } } public var currentSubscription: Product? { let sortedProducts = subscriptions.keys.sorted(by: { $0.price > $1.price }) // first see if we have a product + sub that is set to autorenew for product in sortedProducts { if let _value = subscriptions[product]??.renewalInfo { if _value.willAutoRenew { return product } } } // if no auto renew then return // highest priced that has a sub status for product in sortedProducts { if let _ = subscriptions[product]??.status { return product } } return nil } // for all products return the one that is set // to auto renew public var nextRenewllOption: Product? { if let currentSubscription = currentSubscription, let info = subscriptions[currentSubscription], let status = info?.status { for aStatus in status { if let renewalInfo = try? checkVerified(aStatus.renewalInfo), renewalInfo.willAutoRenew { if let renewToProduct = subscriptions.first(where: { $0.key.id == renewalInfo.autoRenewPreference })?.key { return renewToProduct } } } } return nil } var updateListenerTask: Task? = nil public var expireDate: Date? { Calendar.current.date(byAdding: .day, value: 30, to: firstLaunchDate) ?? nil } private let iapIdentifiers = Set([ // "com.88oakapps.ifeel.IAP.subscriptions.weekly", "com.88oakapps.ifeel.IAP.subscriptions.monthly", "com.88oakapps.ifeel.IAP.subscriptions.yearly" ]) var expireOnTimer: Timer? init() { isLoadingSubscriptions = true //Start a transaction listener as close to app launch as possible so you don't miss any transactions. updateListenerTask = listenForTransactions() updateEverything() } deinit { updateListenerTask?.cancel() } public func updateEverything() { Task { // get current sub from local cache await updatePurchasedProducts() // update local variables to show iap warning / purchase views self.updateShowVariables() // if they have a subscription we dont care about showing the loading indicator if !self.showIAP { DispatchQueue.main.async { self.isLoadingSubscriptions = false } } // During store initialization, request products from the App Store. await requestProducts() // Deliver products that the customer purchases. await updateCustomerProductStatus() self.updateShowVariables() self.setUpdateTimer() DispatchQueue.main.async { self.isLoadingSubscriptions = false } } } private func updateShowVariables() { DispatchQueue.main.async { self.showIAP = self.shouldShowIAP self.showIAPWarning = self.shouldShowIAPWarning } } private func setUpdateTimer() { if !self.showIAPWarning { if let expireOnTimer = expireOnTimer { expireOnTimer.invalidate() } return } if let expireDate = expireDate { expireOnTimer = Timer.init(fire: expireDate, interval: 0, repeats: false, block: { _ in self.updateShowVariables() }) RunLoop.main.add(expireOnTimer!, forMode: .common) } else { if let expireOnTimer = expireOnTimer { expireOnTimer.invalidate() } } } func listenForTransactions() -> Task { 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() self.updateShowVariables() //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") } } } } // fetch all available iap from remote and store locally // in subscriptions @MainActor func requestProducts() async { do { subscriptions.removeAll() //Request products from the App Store using the identifiers that the Products.plist file defines. let storeProducts = try await Product.products(for: iapIdentifiers) //Filter the products into categories based on their type. for product in storeProducts { switch product.type { case .consumable: break case .nonConsumable: break case .autoRenewable: subscriptions.updateValue(nil, forKey: product) case .nonRenewable: break default: //Ignore this product. print("Unknown product") } } } catch { print("Failed product request from the App Store server: \(error)") } } // quickly check current entitlments if we have a sub private func updatePurchasedProducts() async { for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate == nil { self.purchasedProductIDs.insert(transaction.productID) } else { self.purchasedProductIDs.remove(transaction.productID) } } } // fetch all subscriptions and fill out subscriptions with current // status of each @MainActor func updateCustomerProductStatus() async { var purchasedSubscriptions: [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: break case .nonRenewable: break case .autoRenewable: if let subscription = subscriptions.first(where: { $0.key.id == transaction.productID }) { purchasedSubscriptions.append(subscription.key) } default: break } } catch { print() } } for sub in purchasedSubscriptions { guard let statuses = try? await sub.subscription?.status else { return } for status in statuses { switch status.state { case .expired, .revoked: continue default: if let renewalInfo = try? checkVerified(status.renewalInfo) { subscriptions.updateValue((statuses, renewalInfo), forKey: sub) } } } } } func purchase(_ product: Product) async throws -> Transaction? { DispatchQueue.main.async { self.isPurchasing = true } //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() self.updateShowVariables() //Always finish a transaction. await transaction.finish() DispatchQueue.main.async { self.isPurchasing = false } 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: return false case .nonConsumable: return false case .autoRenewable: return subscriptions.keys.contains(product) default: return false } } func checkVerified(_ result: VerificationResult) 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 } } func sortByPrice(_ products: [Product]) -> [Product] { products.sorted(by: { return $0.price < $1.price }) } func colorForIAPButton(iapIdentifier: String) -> Color { if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.weekly" { return DefaultMoodTint.color(forMood: .horrible) } else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.monthly" { return DefaultMoodTint.color(forMood: .average) } else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.yearly" { return DefaultMoodTint.color(forMood: .great) } return .blue } }