SubscriptionManager.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  1. import AppKit
  2. import Combine
  3. import Foundation
  4. import StoreKit
  5. enum PurchaseError: Equatable {
  6. case productsLoadTimeout
  7. case subscriptionUnavailable
  8. case planUnavailable
  9. case purchaseNotVerified
  10. case purchasePending
  11. case noActiveSubscriptions
  12. case noActivePurchases
  13. case restoreSyncTimeout
  14. case generic
  15. var message: String {
  16. switch self {
  17. case .productsLoadTimeout:
  18. "Couldn't load subscription products right now. Check your StoreKit configuration / internet and try again."
  19. case .subscriptionUnavailable:
  20. "This subscription is currently unavailable. Please try again in a moment."
  21. case .planUnavailable:
  22. "This plan isn't available right now. Please try again later."
  23. case .purchaseNotVerified:
  24. "Apple couldn't verify this purchase."
  25. case .purchasePending:
  26. "This purchase is waiting for approval (for example, Ask to Buy)."
  27. case .noActiveSubscriptions:
  28. "No active subscriptions were found for this Apple ID."
  29. case .noActivePurchases:
  30. "No premium purchases were found for this Apple ID."
  31. case .restoreSyncTimeout:
  32. "Couldn't reach the App Store in time. Check your connection and tap Restore Purchases again."
  33. case .generic:
  34. "Something went wrong with your subscription. Please try again."
  35. }
  36. }
  37. }
  38. @MainActor
  39. final class SubscriptionManager: ObservableObject {
  40. private enum ProductLoadError: Error {
  41. case timeout
  42. }
  43. private enum StoreSyncError: Error {
  44. case timeout
  45. }
  46. private static let cachedPremiumKey = "SubscriptionManager.cachedIsPremium"
  47. private static let cachedPremiumPlanKey = "SubscriptionManager.cachedPremiumPlan"
  48. @Published private(set) var productsByID: [String: Product] = [:]
  49. @Published private(set) var isLoadingProducts = false
  50. @Published private(set) var purchasingPlan: PaywallPlan?
  51. @Published private(set) var isRestoringPurchases = false
  52. @Published private(set) var hasPremiumAccess = false
  53. @Published private(set) var hasResolvedPremiumStatus = false
  54. @Published private(set) var activePremiumPlan: PaywallPlan?
  55. @Published private(set) var productLoadError: String?
  56. @Published private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
  57. @Published var purchaseError: PurchaseError?
  58. var isResolvingEntitlements: Bool {
  59. !hasResolvedPremiumStatus
  60. }
  61. var hasLifetimeAccess: Bool {
  62. activePremiumPlan == .lifetime
  63. }
  64. var hasActiveSubscription: Bool {
  65. activePremiumPlan == .monthly || activePremiumPlan == .yearly
  66. }
  67. var hasAllProductsLoaded: Bool {
  68. SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
  69. }
  70. private var inFlightProductLoad: Task<Void, Never>?
  71. private var initialEntitlementTask: Task<Void, Never>?
  72. private var hasStarted = false
  73. init() {
  74. hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumKey)
  75. if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
  76. let plan = PaywallPlan(rawValue: raw) {
  77. activePremiumPlan = plan
  78. }
  79. }
  80. func start() {
  81. guard !hasStarted else { return }
  82. hasStarted = true
  83. Task { await listenForTransactionUpdates() }
  84. initialEntitlementTask = Task {
  85. await refreshPremiumAccess()
  86. await loadProducts(presentLoadingUI: false)
  87. }
  88. }
  89. func ensureEntitlementsResolved() async {
  90. await initialEntitlementTask?.value
  91. }
  92. func eligibleIntroOffer(for plan: PaywallPlan) -> Product.SubscriptionOffer? {
  93. guard let product = product(for: plan) else { return nil }
  94. return eligibleIntroOffer(for: product)
  95. }
  96. func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
  97. guard introOfferEligibleByProductID[product.id] == true,
  98. let offer = product.subscription?.introductoryOffer,
  99. offer.paymentMode == .freeTrial else {
  100. return nil
  101. }
  102. return offer
  103. }
  104. func loadProducts(presentLoadingUI: Bool? = nil) async {
  105. if let existing = inFlightProductLoad {
  106. await existing.value
  107. return
  108. }
  109. let uiFlag = presentLoadingUI
  110. let task = Task { @MainActor in
  111. await performLoadProducts(presentLoadingUI: uiFlag)
  112. }
  113. inFlightProductLoad = task
  114. await task.value
  115. inFlightProductLoad = nil
  116. }
  117. func product(for plan: PaywallPlan) -> Product? {
  118. productsByID[plan.productID]
  119. }
  120. func mainPrice(for plan: PaywallPlan) -> String {
  121. product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
  122. }
  123. func priceSuffix(for plan: PaywallPlan) -> String? {
  124. switch plan {
  125. case .monthly: "/mo"
  126. case .yearly: "/yr"
  127. case .lifetime: nil
  128. }
  129. }
  130. func billingDescription(for plan: PaywallPlan) -> String {
  131. guard let product = product(for: plan) else {
  132. return plan.fallbackBillingDescription
  133. }
  134. if let subscription = product.subscription,
  135. let intro = subscription.introductoryOffer,
  136. intro.paymentMode == .freeTrial {
  137. let trialPeriod = formattedPeriod(intro.period)
  138. let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
  139. return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
  140. }
  141. switch plan {
  142. case .monthly:
  143. return "Billed at \(product.displayPrice) every month"
  144. case .yearly:
  145. return "Billed at \(product.displayPrice) every year"
  146. case .lifetime:
  147. return "One-time payment of \(product.displayPrice)"
  148. }
  149. }
  150. func purchase(_ plan: PaywallPlan) async -> Bool {
  151. purchaseError = nil
  152. var product = productsByID[plan.productID]
  153. if product == nil {
  154. await loadProducts(presentLoadingUI: false)
  155. product = productsByID[plan.productID]
  156. }
  157. guard let product else {
  158. let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
  159. if missingProductIDs.isEmpty {
  160. purchaseError = .subscriptionUnavailable
  161. } else {
  162. purchaseError = .planUnavailable
  163. #if DEBUG
  164. print(
  165. """
  166. [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
  167. To test locally, attach `Paywall.storekit` in Xcode:
  168. Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
  169. """
  170. )
  171. #endif
  172. }
  173. return false
  174. }
  175. purchasingPlan = plan
  176. defer { purchasingPlan = nil }
  177. do {
  178. let result = try await product.purchase()
  179. switch result {
  180. case .success(let verification):
  181. guard case .verified(let transaction) = verification else {
  182. purchaseError = .purchaseNotVerified
  183. return false
  184. }
  185. let trustedPlan = planGrantingPremium(from: transaction)
  186. if let trustedPlan {
  187. applyPremiumAccess(from: transaction)
  188. }
  189. await transaction.finish()
  190. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  191. return hasPremiumAccess
  192. case .userCancelled:
  193. return false
  194. case .pending:
  195. purchaseError = .purchasePending
  196. return false
  197. @unknown default:
  198. return false
  199. }
  200. } catch {
  201. purchaseError = .generic
  202. return false
  203. }
  204. }
  205. func openSubscriptionManagement() {
  206. let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
  207. guard let url = URL(string: urlString) else { return }
  208. NSWorkspace.shared.open(url)
  209. }
  210. func restorePurchases() async {
  211. purchaseError = nil
  212. isRestoringPurchases = true
  213. defer { isRestoringPurchases = false }
  214. do {
  215. try await syncAppStoreWithTimeout(seconds: 30)
  216. } catch is StoreSyncError {
  217. purchaseError = .restoreSyncTimeout
  218. return
  219. } catch {
  220. purchaseError = .generic
  221. return
  222. }
  223. await reconcilePremiumAccess(trustedPlan: nil)
  224. if !hasPremiumAccess {
  225. purchaseError = .noActivePurchases
  226. }
  227. }
  228. private func performLoadProducts(presentLoadingUI: Bool?) async {
  229. let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
  230. let shouldShowLoading = presentLoadingUI ?? missingAny
  231. if !missingAny, presentLoadingUI != true {
  232. purchaseError = nil
  233. await refreshPremiumAccess()
  234. return
  235. }
  236. if shouldShowLoading {
  237. isLoadingProducts = true
  238. }
  239. defer {
  240. if shouldShowLoading {
  241. isLoadingProducts = false
  242. }
  243. }
  244. purchaseError = nil
  245. productLoadError = nil
  246. do {
  247. let loaded = try await loadProductsWithTimeout(seconds: 12)
  248. guard !loaded.isEmpty else {
  249. productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
  250. productsByID = [:]
  251. return
  252. }
  253. var map: [String: Product] = [:]
  254. for product in loaded {
  255. map[product.id] = product
  256. }
  257. productsByID = map
  258. productLoadError = nil
  259. await refreshIntroOfferEligibility()
  260. await refreshPremiumAccess()
  261. } catch is ProductLoadError {
  262. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  263. purchaseError = .productsLoadTimeout
  264. } catch {
  265. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  266. purchaseError = .generic
  267. }
  268. }
  269. private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
  270. try await withThrowingTaskGroup(of: [Product].self) { group in
  271. group.addTask {
  272. try await Product.products(for: SubscriptionProductID.all)
  273. }
  274. group.addTask {
  275. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  276. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  277. throw ProductLoadError.timeout
  278. }
  279. guard let first = try await group.next() else {
  280. throw ProductLoadError.timeout
  281. }
  282. group.cancelAll()
  283. return first
  284. }
  285. }
  286. private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
  287. var resolvedPlan: PaywallPlan?
  288. if let lifetimePlan = await activeLifetimePlan() {
  289. resolvedPlan = lifetimePlan
  290. }
  291. if resolvedPlan == nil {
  292. for await result in Transaction.currentEntitlements {
  293. guard case .verified(let transaction) = result else { continue }
  294. guard let plan = planGrantingPremium(from: transaction) else { continue }
  295. resolvedPlan = preferredPlan(resolvedPlan, plan)
  296. }
  297. }
  298. if resolvedPlan == nil || resolvedPlan != .lifetime {
  299. if let subscriptionPlan = await activeSubscriptionPlan() {
  300. resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
  301. }
  302. }
  303. let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
  304. activePremiumPlan = finalPlan
  305. hasPremiumAccess = finalPlan != nil
  306. hasResolvedPremiumStatus = true
  307. persistPremiumCache()
  308. }
  309. /// Re-reads entitlements from StoreKit, then syncs with the App Store when needed.
  310. /// A verified purchase is never downgraded while the network is still catching up.
  311. private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
  312. await refreshPremiumAccess(trustedPlan: trustedPlan)
  313. guard let trustedPlan, !hasPremiumAccess else { return }
  314. for attempt in 1...3 {
  315. do {
  316. try await syncAppStoreWithTimeout(seconds: 15)
  317. } catch {
  318. continue
  319. }
  320. await refreshPremiumAccess(trustedPlan: trustedPlan)
  321. if hasPremiumAccess { return }
  322. let delayNanoseconds = UInt64(attempt) * 1_500_000_000
  323. try? await Task.sleep(nanoseconds: delayNanoseconds)
  324. }
  325. applyTrustedPlan(trustedPlan)
  326. }
  327. private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
  328. switch (resolvedPlan, trustedPlan) {
  329. case (nil, nil):
  330. nil
  331. case (let resolved?, nil):
  332. resolved
  333. case (nil, let trusted?):
  334. trusted
  335. case (let resolved?, let trusted?):
  336. preferredPlan(resolved, trusted)
  337. }
  338. }
  339. private func applyTrustedPlan(_ plan: PaywallPlan) {
  340. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  341. hasPremiumAccess = true
  342. hasResolvedPremiumStatus = true
  343. persistPremiumCache()
  344. }
  345. private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
  346. try await withThrowingTaskGroup(of: Void.self) { group in
  347. group.addTask {
  348. try await AppStore.sync()
  349. }
  350. group.addTask {
  351. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  352. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  353. throw StoreSyncError.timeout
  354. }
  355. try await group.next()
  356. group.cancelAll()
  357. }
  358. }
  359. private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
  360. guard let plan = planGrantingPremium(from: transaction) else { return }
  361. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  362. hasPremiumAccess = true
  363. hasResolvedPremiumStatus = true
  364. persistPremiumCache()
  365. }
  366. private func persistPremiumCache() {
  367. UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
  368. UserDefaults.standard.set(activePremiumPlan?.rawValue, forKey: Self.cachedPremiumPlanKey)
  369. }
  370. private func refreshIntroOfferEligibility() async {
  371. var eligibility: [String: Bool] = [:]
  372. for product in productsByID.values {
  373. guard let subscription = product.subscription,
  374. subscription.introductoryOffer != nil else {
  375. continue
  376. }
  377. let groupID = subscription.subscriptionGroupID
  378. eligibility[product.id] = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
  379. }
  380. introOfferEligibleByProductID = eligibility
  381. }
  382. private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
  383. guard transactionGrantsPremium(transaction) else { return nil }
  384. return SubscriptionProductID.plan(for: transaction.productID)
  385. }
  386. private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
  387. guard SubscriptionProductID.all.contains(transaction.productID) else { return false }
  388. guard transaction.revocationDate == nil else { return false }
  389. if let expirationDate = transaction.expirationDate, expirationDate < Date() {
  390. return false
  391. }
  392. return true
  393. }
  394. private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
  395. if current == .lifetime || candidate == .lifetime {
  396. return .lifetime
  397. }
  398. if let current {
  399. if current == .yearly || candidate == .yearly {
  400. return .yearly
  401. }
  402. return .monthly
  403. }
  404. return candidate
  405. }
  406. private func activeLifetimePlan() async -> PaywallPlan? {
  407. guard let result = await Transaction.latest(for: SubscriptionProductID.lifetime) else {
  408. return nil
  409. }
  410. guard case .verified(let transaction) = result else { return nil }
  411. return planGrantingPremium(from: transaction)
  412. }
  413. private func activeSubscriptionPlan() async -> PaywallPlan? {
  414. var resolvedPlan: PaywallPlan?
  415. for await result in Transaction.currentEntitlements {
  416. guard case .verified(let transaction) = result else { continue }
  417. guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
  418. resolvedPlan = preferredPlan(resolvedPlan, plan)
  419. }
  420. if resolvedPlan == nil, await hasActiveSubscriptionStatus() {
  421. resolvedPlan = await subscriptionPlanFromProductStatus()
  422. }
  423. return resolvedPlan
  424. }
  425. private func subscriptionPlanFromProductStatus() async -> PaywallPlan? {
  426. if productsByID.isEmpty {
  427. do {
  428. let loaded = try await Product.products(for: SubscriptionProductID.all)
  429. var map: [String: Product] = [:]
  430. for product in loaded { map[product.id] = product }
  431. productsByID = map
  432. } catch {
  433. return nil
  434. }
  435. }
  436. for product in productsByID.values {
  437. guard let subscription = product.subscription else { continue }
  438. guard let plan = SubscriptionProductID.plan(for: product.id), plan != .lifetime else { continue }
  439. do {
  440. let statuses = try await subscription.status
  441. let isActive = statuses.contains { status in
  442. switch status.state {
  443. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  444. return true
  445. case .expired, .revoked:
  446. return false
  447. default:
  448. return false
  449. }
  450. }
  451. if isActive { return plan }
  452. } catch {
  453. continue
  454. }
  455. }
  456. return nil
  457. }
  458. private func hasActiveSubscriptionStatus() async -> Bool {
  459. await subscriptionPlanFromProductStatus() != nil
  460. }
  461. private func listenForTransactionUpdates() async {
  462. for await update in Transaction.updates {
  463. guard case .verified(let transaction) = update else { continue }
  464. let trustedPlan = planGrantingPremium(from: transaction)
  465. if let trustedPlan {
  466. applyPremiumAccess(from: transaction)
  467. }
  468. await transaction.finish()
  469. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  470. }
  471. }
  472. private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
  473. switch period.unit {
  474. case .day where period.value == 1: "1 Day"
  475. case .day: "\(period.value) Days"
  476. case .week where period.value == 1: "1 Week"
  477. case .week: "\(period.value) Weeks"
  478. case .month where period.value == 1: "1 Month"
  479. case .month: "\(period.value) Months"
  480. case .year where period.value == 1: "1 Year"
  481. case .year: "\(period.value) Years"
  482. @unknown default: "\(period.value) Days"
  483. }
  484. }
  485. private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
  486. switch period.unit {
  487. case .day: period.value == 1 ? "day" : "\(period.value) days"
  488. case .week: period.value == 1 ? "week" : "\(period.value) weeks"
  489. case .month: period.value == 1 ? "month" : "\(period.value) months"
  490. case .year: period.value == 1 ? "year" : "\(period.value) years"
  491. @unknown default: "billing period"
  492. }
  493. }
  494. }
  495. // MARK: - App Rating
  496. enum RatingPromptSource {
  497. case postPurchase
  498. case usageMilestone
  499. }
  500. @MainActor
  501. final class AppRatingManager: ObservableObject {
  502. static let hasRatedKey = "hasRatedApp"
  503. static let hasShownUsagePromptKey = "hasShownUsageRatingPrompt"
  504. private static let continuousUsageThreshold: TimeInterval = 30 * 60
  505. @Published private(set) var showRatingPrompt = false
  506. @Published private(set) var ratingPromptSource: RatingPromptSource?
  507. private let defaults: UserDefaults
  508. private var isPremium = false
  509. private var continuousSessionStart: Date?
  510. private var usageTimerTask: Task<Void, Never>?
  511. private var cancellables = Set<AnyCancellable>()
  512. private var hasRatedApp: Bool {
  513. defaults.bool(forKey: Self.hasRatedKey)
  514. }
  515. private var hasShownUsagePrompt: Bool {
  516. defaults.bool(forKey: Self.hasShownUsagePromptKey)
  517. }
  518. init(defaults: UserDefaults = .standard) {
  519. self.defaults = defaults
  520. observeAppLifecycle()
  521. }
  522. func updatePremiumStatus(_ isPremium: Bool) {
  523. self.isPremium = isPremium
  524. guard isPremium, !hasRatedApp, !hasShownUsagePrompt else {
  525. pauseUsageTracking()
  526. return
  527. }
  528. if NSApplication.shared.isActive {
  529. beginContinuousUsageSession()
  530. }
  531. }
  532. func handleSubscriptionPurchased() {
  533. guard !hasRatedApp else { return }
  534. pauseUsageTracking()
  535. ratingPromptSource = .postPurchase
  536. showRatingPrompt = true
  537. }
  538. func userAcceptedRating() {
  539. showRatingPrompt = false
  540. ratingPromptSource = nil
  541. defaults.set(true, forKey: Self.hasRatedKey)
  542. pauseUsageTracking()
  543. requestAppStoreReview()
  544. }
  545. func userDeclinedRating() {
  546. let source = ratingPromptSource
  547. showRatingPrompt = false
  548. ratingPromptSource = nil
  549. if source == .usageMilestone {
  550. defaults.set(true, forKey: Self.hasShownUsagePromptKey)
  551. pauseUsageTracking()
  552. return
  553. }
  554. guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
  555. if NSApplication.shared.isActive {
  556. beginContinuousUsageSession()
  557. }
  558. }
  559. private func observeAppLifecycle() {
  560. NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
  561. .sink { [weak self] _ in
  562. self?.appDidBecomeActive()
  563. }
  564. .store(in: &cancellables)
  565. NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)
  566. .sink { [weak self] _ in
  567. self?.appDidResignActive()
  568. }
  569. .store(in: &cancellables)
  570. }
  571. private func appDidBecomeActive() {
  572. guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
  573. beginContinuousUsageSession()
  574. }
  575. private func appDidResignActive() {
  576. pauseUsageTracking()
  577. }
  578. private func beginContinuousUsageSession() {
  579. guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
  580. continuousSessionStart = Date()
  581. startUsageTimer()
  582. }
  583. private func pauseUsageTracking() {
  584. continuousSessionStart = nil
  585. usageTimerTask?.cancel()
  586. usageTimerTask = nil
  587. }
  588. private func startUsageTimer() {
  589. usageTimerTask?.cancel()
  590. usageTimerTask = Task { [weak self] in
  591. while !Task.isCancelled {
  592. try? await Task.sleep(nanoseconds: 1_000_000_000)
  593. guard !Task.isCancelled else { break }
  594. self?.checkContinuousUsageThreshold()
  595. }
  596. }
  597. }
  598. private func checkContinuousUsageThreshold() {
  599. guard
  600. let sessionStart = continuousSessionStart,
  601. isPremium,
  602. !hasRatedApp,
  603. !hasShownUsagePrompt,
  604. !showRatingPrompt
  605. else {
  606. return
  607. }
  608. let elapsed = Date().timeIntervalSince(sessionStart)
  609. guard elapsed >= Self.continuousUsageThreshold else { return }
  610. pauseUsageTracking()
  611. defaults.set(true, forKey: Self.hasShownUsagePromptKey)
  612. ratingPromptSource = .usageMilestone
  613. showRatingPrompt = true
  614. }
  615. private func requestAppStoreReview() {
  616. SKStoreReviewController.requestReview()
  617. }
  618. }