Bläddra i källkod

Fix premium unlock on slow networks after StoreKit purchase.

Preserve verified transactions during entitlement sync, auto-retry App Store sync after purchase, and improve restore flow with timeout handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 veckor sedan
förälder
incheckning
ac83e01f1a
2 ändrade filer med 103 tillägg och 13 borttagningar
  1. 94 11
      Reddit App/Managers/SubscriptionManager.swift
  2. 9 2
      Reddit App/Views/PaywallView.swift

+ 94 - 11
Reddit App/Managers/SubscriptionManager.swift

@@ -11,6 +11,7 @@ enum PurchaseError: Equatable {
     case purchasePending
     case noActiveSubscriptions
     case noActivePurchases
+    case restoreSyncTimeout
     case generic
 
     var message: String {
@@ -29,6 +30,8 @@ enum PurchaseError: Equatable {
             "No active subscriptions were found for this Apple ID."
         case .noActivePurchases:
             "No premium purchases were found for this Apple ID."
+        case .restoreSyncTimeout:
+            "Couldn't reach the App Store in time. Check your connection and tap Restore Purchases again."
         case .generic:
             "Something went wrong with your subscription. Please try again."
         }
@@ -41,9 +44,14 @@ final class SubscriptionManager: ObservableObject {
         case timeout
     }
 
+    private enum StoreSyncError: Error {
+        case timeout
+    }
+
     @Published private(set) var productsByID: [String: Product] = [:]
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var purchasingPlan: PaywallPlan?
+    @Published private(set) var isRestoringPurchases = false
     @Published private(set) var hasPremiumAccess = false
     @Published private(set) var hasResolvedPremiumStatus = false
     @Published private(set) var activePremiumPlan: PaywallPlan?
@@ -159,12 +167,13 @@ final class SubscriptionManager: ObservableObject {
                     return false
                 }
 
-                if transactionGrantsPremium(transaction) {
+                let trustedPlan = planGrantingPremium(from: transaction)
+                if let trustedPlan {
                     applyPremiumAccess(from: transaction)
                 }
 
                 await transaction.finish()
-                await refreshPremiumAccess()
+                await reconcilePremiumAccess(trustedPlan: trustedPlan)
                 return hasPremiumAccess
             case .userCancelled:
                 return false
@@ -187,14 +196,22 @@ final class SubscriptionManager: ObservableObject {
 
     func restorePurchases() async {
         purchaseError = nil
+        isRestoringPurchases = true
+        defer { isRestoringPurchases = false }
+
         do {
-            try await AppStore.sync()
-            await refreshPremiumAccess()
-            if !hasPremiumAccess {
-                purchaseError = .noActivePurchases
-            }
+            try await syncAppStoreWithTimeout(seconds: 30)
+        } catch is StoreSyncError {
+            purchaseError = .restoreSyncTimeout
+            return
         } catch {
             purchaseError = .generic
+            return
+        }
+
+        await reconcilePremiumAccess(trustedPlan: nil)
+        if !hasPremiumAccess {
+            purchaseError = .noActivePurchases
         }
     }
 
@@ -252,7 +269,7 @@ final class SubscriptionManager: ObservableObject {
         }
     }
 
-    private func refreshPremiumAccess() async {
+    private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
         var resolvedPlan: PaywallPlan?
 
         if let lifetimePlan = await activeLifetimePlan() {
@@ -273,11 +290,71 @@ final class SubscriptionManager: ObservableObject {
             }
         }
 
-        activePremiumPlan = resolvedPlan
-        hasPremiumAccess = resolvedPlan != nil
+        let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
+        activePremiumPlan = finalPlan
+        hasPremiumAccess = finalPlan != nil
         hasResolvedPremiumStatus = true
     }
 
+    /// Re-reads entitlements from StoreKit, then syncs with the App Store when needed.
+    /// A verified purchase is never downgraded while the network is still catching up.
+    private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
+        await refreshPremiumAccess(trustedPlan: trustedPlan)
+
+        guard let trustedPlan, !hasPremiumAccess else { return }
+
+        for attempt in 1...3 {
+            do {
+                try await syncAppStoreWithTimeout(seconds: 15)
+            } catch {
+                continue
+            }
+
+            await refreshPremiumAccess(trustedPlan: trustedPlan)
+            if hasPremiumAccess { return }
+
+            let delayNanoseconds = UInt64(attempt) * 1_500_000_000
+            try? await Task.sleep(nanoseconds: delayNanoseconds)
+        }
+
+        applyTrustedPlan(trustedPlan)
+    }
+
+    private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
+        switch (resolvedPlan, trustedPlan) {
+        case (nil, nil):
+            nil
+        case (let resolved?, nil):
+            resolved
+        case (nil, let trusted?):
+            trusted
+        case (let resolved?, let trusted?):
+            preferredPlan(resolved, trusted)
+        }
+    }
+
+    private func applyTrustedPlan(_ plan: PaywallPlan) {
+        activePremiumPlan = preferredPlan(activePremiumPlan, plan)
+        hasPremiumAccess = true
+        hasResolvedPremiumStatus = true
+    }
+
+    private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
+        try await withThrowingTaskGroup(of: Void.self) { group in
+            group.addTask {
+                try await AppStore.sync()
+            }
+            group.addTask {
+                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
+                try await Task.sleep(nanoseconds: timeoutNanoseconds)
+                throw StoreSyncError.timeout
+            }
+
+            try await group.next()
+            group.cancelAll()
+        }
+    }
+
     private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
         guard let plan = planGrantingPremium(from: transaction) else { return }
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
@@ -380,8 +457,14 @@ final class SubscriptionManager: ObservableObject {
     private func listenForTransactionUpdates() async {
         for await update in Transaction.updates {
             guard case .verified(let transaction) = update else { continue }
+
+            let trustedPlan = planGrantingPremium(from: transaction)
+            if let trustedPlan {
+                applyPremiumAccess(from: transaction)
+            }
+
             await transaction.finish()
-            await refreshPremiumAccess()
+            await reconcilePremiumAccess(trustedPlan: trustedPlan)
         }
     }
 

+ 9 - 2
Reddit App/Views/PaywallView.swift

@@ -12,7 +12,7 @@ struct PaywallView: View {
     }
 
     private var buttonsBusy: Bool {
-        subscriptions.purchasingPlan != nil
+        subscriptions.purchasingPlan != nil || subscriptions.isRestoringPurchases
     }
 
     private var isPremium: Bool {
@@ -214,11 +214,18 @@ struct PaywallView: View {
 
             Spacer(minLength: metrics.footerLinkSpacing)
 
-            footerLink(PaywallContent.restorePurchases, metrics: metrics) {
+            footerLink(
+                subscriptions.isRestoringPurchases ? "Restoring..." : PaywallContent.restorePurchases,
+                metrics: metrics
+            ) {
                 Task {
                     await subscriptions.restorePurchases()
+                    if subscriptions.hasPremiumAccess {
+                        onDismiss()
+                    }
                 }
             }
+            .disabled(subscriptions.isRestoringPurchases)
 
             Spacer(minLength: metrics.footerLinkSpacing)