Files
Reflect/Shared/IAPManager.swift
Trey t 1e8acfa320 Add offline resilience to subscription status checks
Cache subscription expiration date so premium access survives
offline launches and transient StoreKit failures. Restores cached
state synchronously on init to eliminate loading flash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:56:14 -06:00

311 lines
10 KiB
Swift

//
// IAPManager.swift
// Feels
//
// Refactored StoreKit 2 subscription manager with clean state model.
//
import Foundation
import StoreKit
import SwiftUI
import os.log
// MARK: - Subscription State
enum SubscriptionState: Equatable {
case unknown
case subscribed(expirationDate: Date?, willAutoRenew: Bool)
case inTrial(daysRemaining: Int)
case trialExpired
case expired
}
// MARK: - IAPManager
@MainActor
class IAPManager: ObservableObject {
// MARK: - Shared Instance
/// Shared instance for service-level access (e.g., HealthKit gating)
static let shared = IAPManager()
// MARK: - Debug Toggle
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
/// Set to `false` to test trial/subscription behavior in DEBUG builds
#if DEBUG
static let bypassSubscription = false
#else
static let bypassSubscription = false
#endif
// MARK: - Constants
static let subscriptionGroupID = "21914363"
private let productIdentifiers: Set<String> = [
"com.88oakapps.feels.IAP.subscriptions.monthly",
"com.88oakapps.feels.IAP.subscriptions.yearly"
]
private let trialDays = 30
// MARK: - Published State
@Published private(set) var state: SubscriptionState = .unknown
@Published private(set) var availableProducts: [Product] = []
@Published private(set) var currentProduct: Product? = nil
@Published private(set) var isLoading = false
// MARK: - Storage
/// Reads firstLaunchDate directly from UserDefaults to ensure we always get the latest value
/// (Using @AppStorage in a class doesn't auto-sync when other components update the same key)
private var firstLaunchDate: Date {
get {
GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue) as? Date ?? Date()
}
set {
GroupUserDefaults.groupDefaults.set(newValue, forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue)
}
}
// MARK: - Private
private var updateListenerTask: Task<Void, Error>?
/// Last time subscription status was checked (for throttling)
private var lastStatusCheckTime: Date?
/// Minimum interval between status checks (5 minutes)
private let statusCheckInterval: TimeInterval = 300
// MARK: - Computed Properties
var isSubscribed: Bool {
if case .subscribed = state { return true }
return false
}
var hasFullAccess: Bool {
if Self.bypassSubscription { return true }
switch state {
case .subscribed, .inTrial:
return true
case .unknown, .trialExpired, .expired:
return false
}
}
var shouldShowPaywall: Bool {
if Self.bypassSubscription { return false }
switch state {
case .trialExpired, .expired:
return true
case .unknown, .subscribed, .inTrial:
return false
}
}
var shouldShowTrialWarning: Bool {
if case .inTrial = state { return true }
return false
}
var daysLeftInTrial: Int {
if case .inTrial(let days) = state { return days }
return 0
}
var trialExpirationDate: Date? {
Calendar.current.date(byAdding: .day, value: trialDays, to: firstLaunchDate)
}
/// Products sorted by price (lowest first)
var sortedProducts: [Product] {
availableProducts.sorted { $0.price < $1.price }
}
// MARK: - Initialization
init() {
restoreCachedSubscriptionState()
updateListenerTask = listenForTransactions()
Task {
await checkSubscriptionStatus()
}
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - Public Methods
/// Check subscription status - call on app launch and when becoming active
/// Throttled to avoid excessive StoreKit calls on rapid foreground transitions
func checkSubscriptionStatus() async {
// Throttle: skip if we checked recently (unless state is unknown)
if state != .unknown,
let lastCheck = lastStatusCheckTime,
Date().timeIntervalSince(lastCheck) < statusCheckInterval {
return
}
// Only update isLoading if value actually changes to avoid unnecessary view updates
if !isLoading { isLoading = true }
defer {
if isLoading { isLoading = false }
lastStatusCheckTime = Date()
}
// Fetch available products
await loadProducts()
// Check for active subscription
let hasActiveSubscription = await checkForActiveSubscription()
if hasActiveSubscription {
// State already set in checkForActiveSubscription cache it
if case .subscribed(let expiration, _) = state {
cacheSubscriptionExpiration(expiration)
}
syncSubscriptionStatusToUserDefaults()
return
}
// Live check found no active subscription.
// Before downgrading, check if cached expiration is still valid (covers offline/transient failures).
let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date
if let expiration = cachedExpiration, expiration > Date() {
state = .subscribed(expirationDate: expiration, willAutoRenew: false)
syncSubscriptionStatusToUserDefaults()
return
}
// Subscription genuinely gone clear cache and fall back to trial
cacheSubscriptionExpiration(nil)
updateTrialState()
}
/// Sync subscription status to UserDefaults for widget access
private func syncSubscriptionStatusToUserDefaults() {
let accessValue = Self.bypassSubscription ? true : hasFullAccess
GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
}
/// Cache subscription state so premium access survives offline/failed checks
private func cacheSubscriptionExpiration(_ date: Date?) {
GroupUserDefaults.groupDefaults.set(date, forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue)
}
/// Restore cached subscription state on launch (before async check completes)
private func restoreCachedSubscriptionState() {
let hasActive = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
guard hasActive else { return }
let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date
// If we have a cached expiration and it's still in the future, restore subscribed state
if let expiration = cachedExpiration, expiration > Date() {
state = .subscribed(expirationDate: expiration, willAutoRenew: false)
} else if cachedExpiration == nil && hasActive {
// Had access but no expiration cached (e.g., upgraded from older version) trust it
state = .subscribed(expirationDate: nil, willAutoRenew: false)
}
}
/// Restore purchases
func restore() async {
do {
try await AppStore.sync()
await checkSubscriptionStatus()
} catch {
AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)")
}
}
// MARK: - Private Methods
private func loadProducts() async {
do {
let products = try await Product.products(for: productIdentifiers)
availableProducts = products.filter { $0.type == .autoRenewable }
} catch {
AppLogger.iap.error("Failed to load products: \(error.localizedDescription)")
}
}
private func checkForActiveSubscription() async -> Bool {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
// Skip revoked transactions
if transaction.revocationDate != nil { continue }
// Check if this is one of our subscription products
guard productIdentifiers.contains(transaction.productID) else { continue }
// Get the product for this transaction
currentProduct = availableProducts.first { $0.id == transaction.productID }
// Get renewal info
if let product = currentProduct,
let subscription = product.subscription,
let statuses = try? await subscription.status {
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
switch status.state {
case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
state = .subscribed(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .expired, .revoked:
continue
default:
continue
}
}
}
// Fallback if we couldn't get detailed status
state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false)
return true
}
// No active subscription found
currentProduct = nil
return false
}
private func updateTrialState() {
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0
let daysRemaining = trialDays - daysSinceInstall
if daysRemaining > 0 {
state = .inTrial(daysRemaining: daysRemaining)
} else {
state = .trialExpired
}
syncSubscriptionStatusToUserDefaults()
}
private func listenForTransactions() -> Task<Void, Error> {
Task.detached { [weak self] in
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await transaction.finish()
await self?.checkSubscriptionStatus()
}
}
}
}