PremiumStore.swift 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import Combine
  2. import Foundation
  3. import StoreKit
  4. /// Product identifiers — create matching Non-Consumable + Auto-Renewable Subscription in App Store Connect.
  5. enum PremiumProductID: String, CaseIterable {
  6. case perpetual = "com.googleapps.lifetime"
  7. case yearly = "com.googleapps.yearly"
  8. }
  9. @MainActor
  10. final class PremiumStore: ObservableObject {
  11. static let shared = PremiumStore()
  12. @Published private(set) var products: [Product] = []
  13. @Published private(set) var isPremiumUnlocked = false
  14. @Published private(set) var activePremiumProductID: PremiumProductID?
  15. @Published private(set) var isLoadingProducts = false
  16. @Published private(set) var loadError: String?
  17. @Published private(set) var purchaseInProgress = false
  18. @Published private(set) var restoreInProgress = false
  19. @Published var purchaseError: String?
  20. @Published var restoreMessage: String?
  21. private var transactionListener: Task<Void, Never>?
  22. private init() {
  23. transactionListener = Task { await listenForTransactions() }
  24. Task { await refreshEntitlements() }
  25. }
  26. deinit {
  27. transactionListener?.cancel()
  28. }
  29. func product(for premiumID: PremiumProductID) -> Product? {
  30. products.first { $0.id == premiumID.rawValue }
  31. }
  32. func displayPrice(for premiumID: PremiumProductID) -> String? {
  33. product(for: premiumID)?.displayPrice
  34. }
  35. func loadProducts() async {
  36. await loadProducts(attempt: 1)
  37. }
  38. private func loadProducts(attempt: Int) async {
  39. isLoadingProducts = true
  40. loadError = nil
  41. defer { isLoadingProducts = false }
  42. do {
  43. let ids = PremiumProductID.allCases.map(\.rawValue)
  44. let loaded = try await Product.products(for: ids)
  45. let order = Dictionary(uniqueKeysWithValues: ids.enumerated().map { ($1, $0) })
  46. products = loaded.sorted { (order[$0.id] ?? Int.max) < (order[$1.id] ?? Int.max) }
  47. if loaded.isEmpty {
  48. loadError = """
  49. No products returned for IDs: \(ids.joined(separator: ", ")).
  50. If testing locally, confirm Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration = Products.storekit, then stop/run again.
  51. """
  52. } else {
  53. let loadedIDs = Set(loaded.map(\.id))
  54. let missing = ids.filter { !loadedIDs.contains($0) }
  55. if !missing.isEmpty {
  56. loadError = "Store returned partial products. Missing IDs: \(missing.joined(separator: ", "))"
  57. }
  58. }
  59. if products.isEmpty && attempt == 1 {
  60. try? await Task.sleep(nanoseconds: 800_000_000)
  61. await loadProducts(attempt: 2)
  62. }
  63. } catch {
  64. let ids = PremiumProductID.allCases.map(\.rawValue).joined(separator: ", ")
  65. loadError = "StoreKit error: \(error.localizedDescription). Requested IDs: \(ids)"
  66. if attempt == 1 {
  67. try? await Task.sleep(nanoseconds: 800_000_000)
  68. await loadProducts(attempt: 2)
  69. }
  70. }
  71. }
  72. func purchase(_ product: Product) async -> Bool {
  73. purchaseInProgress = true
  74. purchaseError = nil
  75. defer { purchaseInProgress = false }
  76. do {
  77. let result = try await product.purchase()
  78. switch result {
  79. case .success(let verification):
  80. switch verification {
  81. case .verified(let transaction):
  82. if PremiumProductID(rawValue: transaction.productID) != nil {
  83. isPremiumUnlocked = true
  84. RatingPromptManager.shared.requestRatingIfNeeded()
  85. }
  86. await transaction.finish()
  87. await refreshEntitlements()
  88. return true
  89. case .unverified(_, let error):
  90. purchaseError = error.localizedDescription
  91. return false
  92. }
  93. case .userCancelled:
  94. return false
  95. case .pending:
  96. purchaseError = "Purchase is pending (e.g. Ask to Buy)."
  97. return false
  98. @unknown default:
  99. return false
  100. }
  101. } catch {
  102. purchaseError = error.localizedDescription
  103. return false
  104. }
  105. }
  106. func restorePurchases() async {
  107. restoreInProgress = true
  108. restoreMessage = nil
  109. loadError = nil
  110. defer { restoreInProgress = false }
  111. do {
  112. try await AppStore.sync()
  113. await refreshEntitlements()
  114. if isPremiumUnlocked {
  115. restoreMessage = "Purchases restored successfully."
  116. } else {
  117. restoreMessage = "No active purchases were found for this Apple ID."
  118. }
  119. } catch {
  120. loadError = error.localizedDescription
  121. restoreMessage = nil
  122. }
  123. }
  124. func refreshEntitlements() async {
  125. let state = await resolvePremiumState()
  126. isPremiumUnlocked = state.isUnlocked
  127. activePremiumProductID = state.activeProductID
  128. }
  129. private func listenForTransactions() async {
  130. for await result in Transaction.updates {
  131. guard case .verified(let transaction) = result else { continue }
  132. await transaction.finish()
  133. await refreshEntitlements()
  134. }
  135. }
  136. private func resolvePremiumState() async -> (isUnlocked: Bool, activeProductID: PremiumProductID?) {
  137. let validIDs = Set(PremiumProductID.allCases.map(\.rawValue))
  138. var found: [PremiumProductID] = []
  139. for await result in Transaction.currentEntitlements {
  140. guard case .verified(let transaction) = result else { continue }
  141. guard validIDs.contains(transaction.productID) else { continue }
  142. if isTransactionActive(transaction) {
  143. if let productID = PremiumProductID(rawValue: transaction.productID) {
  144. found.append(productID)
  145. }
  146. }
  147. }
  148. if let preferred = preferredActiveProductID(from: found) {
  149. return (true, preferred)
  150. }
  151. // Fallback path for cases where `currentEntitlements` is briefly empty.
  152. found.removeAll()
  153. for id in PremiumProductID.allCases.map(\.rawValue) {
  154. guard let latestResult = await Transaction.latest(for: id) else { continue }
  155. guard case .verified(let transaction) = latestResult else { continue }
  156. if isTransactionActive(transaction) {
  157. if let productID = PremiumProductID(rawValue: transaction.productID) {
  158. found.append(productID)
  159. }
  160. }
  161. }
  162. if let preferred = preferredActiveProductID(from: found) {
  163. return (true, preferred)
  164. }
  165. return (false, nil)
  166. }
  167. private func isTransactionActive(_ transaction: Transaction) -> Bool {
  168. if transaction.revocationDate != nil {
  169. return false
  170. }
  171. if let expirationDate = transaction.expirationDate, expirationDate <= Date() {
  172. return false
  173. }
  174. return true
  175. }
  176. private func preferredActiveProductID(from ids: [PremiumProductID]) -> PremiumProductID? {
  177. if ids.contains(.perpetual) {
  178. return .perpetual
  179. }
  180. if ids.contains(.yearly) {
  181. return .yearly
  182. }
  183. return nil
  184. }
  185. }