Files
Reflect/Shared/IAPManager.swift
Trey t 488832b777 WIP
2022-12-23 09:43:48 -06:00

358 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
See LICENSE folder for this samples 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 subscriptions = [Product: (status: [Product.SubscriptionInfo.Status], renewalInfo: RenewalInfo)?]()
@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 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<Void, Error>? = nil
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
}
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()
refresh()
setUpdateTimer()
}
deinit {
updateListenerTask?.cancel()
}
func setUpdateTimer() {
if let expireDate = expireDate {
expireOnTimer = Timer.init(fire: expireDate, interval: 0, repeats: false, block: { _ in
self.decideShowIAP()
self.decideShowIAPWarning()
})
RunLoop.main.add(expireOnTimer!, forMode: .common)
} else {
if let expireOnTimer = expireOnTimer {
expireOnTimer.invalidate()
}
}
}
func refresh() {
Task {
//During store initialization, request products from the App Store.
await requestProducts()
//Deliver products that the customer purchases.
await updateCustomerProductStatus()
decideShowIAP()
decideShowIAPWarning()
DispatchQueue.main.async {
self.isLoadingSubscriptions = false
}
}
}
func decideShowIAP() {
// if we have a sub in the subscriptions dict
// then we dont need to show
for (_, value) in self.subscriptions {
if value != nil {
DispatchQueue.main.async {
self.showIAP = false
}
return
}
}
var tmpShowIAP = true
// if its passed 30 days with no sub
if daysLeftBeforeIAP <= 0 {
tmpShowIAP = true
} else {
tmpShowIAP = false
}
DispatchQueue.main.async {
self.showIAP = tmpShowIAP
}
}
func decideShowIAPWarning() {
DispatchQueue.main.async {
if self.currentSubscription != nil {
self.showIAPWarning = false
} else {
self.showIAPWarning = true
}
}
}
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")
}
}
}
}
// 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)")
}
}
@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 isnt, 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:
print("subscriptions2 \(subscriptions)")
if let subscription = subscriptions.first(where: {
$0.key.id == transaction.productID
}) {
purchasedSubscriptions.append(subscription.key)
}
default:
break
}
} catch {
print()
}
}
print("purchasedSubscriptions \(purchasedSubscriptions)")
if !purchasedSubscriptions.isEmpty {
self.showIAP = false
self.showIAPWarning = false
}
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? {
//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()
decideShowIAP()
decideShowIAPWarning()
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<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
}
}
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
}
}