|
|
@@ -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)
|
|
|
}
|
|
|
}
|
|
|
|