|
@@ -73,6 +73,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
private var initialEntitlementTask: Task<Void, Never>?
|
|
private var initialEntitlementTask: Task<Void, Never>?
|
|
|
private var entitlementRefreshTask: Task<Void, Never>?
|
|
private var entitlementRefreshTask: Task<Void, Never>?
|
|
|
private var hasStarted = false
|
|
private var hasStarted = false
|
|
|
|
|
+ private var launchedWithCachedPremium = false
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
private var lastBackgroundEntitlementRefresh: Date?
|
|
private var lastBackgroundEntitlementRefresh: Date?
|
|
|
|
|
|
|
@@ -84,6 +85,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
// Optimistic restore so returning subscribers aren't paywalled while StoreKit resolves.
|
|
// Optimistic restore so returning subscribers aren't paywalled while StoreKit resolves.
|
|
|
if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
|
|
if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
|
|
|
|
|
+ launchedWithCachedPremium = true
|
|
|
hasPremiumAccess = true
|
|
hasPremiumAccess = true
|
|
|
if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
|
|
if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
|
|
|
let plan = PaywallPlan(rawValue: planRaw) {
|
|
let plan = PaywallPlan(rawValue: planRaw) {
|
|
@@ -101,6 +103,22 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
initialEntitlementTask = Task {
|
|
initialEntitlementTask = Task {
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
|
|
+
|
|
|
|
|
+ let shouldReconcileWithAppStore = launchedWithCachedPremium
|
|
|
|
|
+ || (!hasPremiumAccess && hasEverPurchasedPremium)
|
|
|
|
|
+
|
|
|
|
|
+ if shouldReconcileWithAppStore {
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await syncAppStoreWithTimeout(seconds: 15)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ #if DEBUG
|
|
|
|
|
+ print("[SubscriptionManager] App Store sync failed during premium reconciliation")
|
|
|
|
|
+ #endif
|
|
|
|
|
+ }
|
|
|
|
|
+ launchedWithCachedPremium = false
|
|
|
|
|
+ await refreshPremiumAccess()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
await loadProducts(presentLoadingUI: false)
|
|
await loadProducts(presentLoadingUI: false)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -366,17 +384,31 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if resolvedPlan == nil {
|
|
|
|
|
+ resolvedPlan = await activePlanFromLatestTransactions()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
|
|
let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
|
|
|
- activePremiumPlan = finalPlan
|
|
|
|
|
- hasPremiumAccess = finalPlan != nil
|
|
|
|
|
|
|
|
|
|
- if hasPremiumAccess {
|
|
|
|
|
|
|
+ if let finalPlan {
|
|
|
|
|
+ activePremiumPlan = finalPlan
|
|
|
|
|
+ hasPremiumAccess = true
|
|
|
|
|
+ launchedWithCachedPremium = false
|
|
|
markAsHavingPurchasedPremium()
|
|
markAsHavingPurchasedPremium()
|
|
|
|
|
+ } else if launchedWithCachedPremium, activePremiumPlan != nil {
|
|
|
|
|
+ // StoreKit can lag behind on launch; keep the cached plan until reconciliation finishes.
|
|
|
|
|
+ hasPremiumAccess = true
|
|
|
|
|
+ } else {
|
|
|
|
|
+ activePremiumPlan = nil
|
|
|
|
|
+ hasPremiumAccess = false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- hasResolvedPremiumStatus = true
|
|
|
|
|
- persistPremiumCache()
|
|
|
|
|
- await syncPurchaseHistory()
|
|
|
|
|
|
|
+ let resolved = !launchedWithCachedPremium
|
|
|
|
|
+ if resolved {
|
|
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
|
|
+ persistPremiumCache()
|
|
|
|
|
+ await syncPurchaseHistory()
|
|
|
|
|
+ }
|
|
|
await refreshTrialOffers()
|
|
await refreshTrialOffers()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -414,6 +446,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func applyTrustedPlan(_ plan: PaywallPlan) {
|
|
private func applyTrustedPlan(_ plan: PaywallPlan) {
|
|
|
|
|
+ launchedWithCachedPremium = false
|
|
|
activePremiumPlan = preferredPlan(activePremiumPlan, plan)
|
|
activePremiumPlan = preferredPlan(activePremiumPlan, plan)
|
|
|
hasPremiumAccess = true
|
|
hasPremiumAccess = true
|
|
|
hasResolvedPremiumStatus = true
|
|
hasResolvedPremiumStatus = true
|
|
@@ -422,6 +455,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func revokePremiumAccess() {
|
|
private func revokePremiumAccess() {
|
|
|
|
|
+ launchedWithCachedPremium = false
|
|
|
activePremiumPlan = nil
|
|
activePremiumPlan = nil
|
|
|
hasPremiumAccess = false
|
|
hasPremiumAccess = false
|
|
|
hasResolvedPremiumStatus = true
|
|
hasResolvedPremiumStatus = true
|
|
@@ -448,6 +482,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
|
|
private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
|
|
|
guard let plan = planGrantingPremium(from: transaction) else { return }
|
|
guard let plan = planGrantingPremium(from: transaction) else { return }
|
|
|
|
|
+ launchedWithCachedPremium = false
|
|
|
activePremiumPlan = preferredPlan(activePremiumPlan, plan)
|
|
activePremiumPlan = preferredPlan(activePremiumPlan, plan)
|
|
|
hasPremiumAccess = true
|
|
hasPremiumAccess = true
|
|
|
hasResolvedPremiumStatus = true
|
|
hasResolvedPremiumStatus = true
|
|
@@ -561,6 +596,19 @@ final class SubscriptionManager: ObservableObject {
|
|
|
return candidate
|
|
return candidate
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func activePlanFromLatestTransactions() async -> PaywallPlan? {
|
|
|
|
|
+ var resolvedPlan: PaywallPlan?
|
|
|
|
|
+
|
|
|
|
|
+ for productID in SubscriptionProductID.all {
|
|
|
|
|
+ guard let result = await Transaction.latest(for: productID) else { continue }
|
|
|
|
|
+ guard case .verified(let transaction) = result else { continue }
|
|
|
|
|
+ guard let plan = planGrantingPremium(from: transaction) else { continue }
|
|
|
|
|
+ resolvedPlan = preferredPlan(resolvedPlan, plan)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return resolvedPlan
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func activeLifetimePlan() async -> PaywallPlan? {
|
|
private func activeLifetimePlan() async -> PaywallPlan? {
|
|
|
guard let result = await Transaction.latest(for: SubscriptionProductID.lifetime) else {
|
|
guard let result = await Transaction.latest(for: SubscriptionProductID.lifetime) else {
|
|
|
return nil
|
|
return nil
|