From 1e8acfa320c6f216fde29a15273c73147a2063f1 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 10 Feb 2026 09:56:14 -0600 Subject: [PATCH] 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 --- Configuration.storekit | 139 -------------------------- Shared/IAPManager.swift | 39 +++++++- Shared/Models/UserDefaultsStore.swift | 1 + 3 files changed, 38 insertions(+), 141 deletions(-) delete mode 100644 Configuration.storekit diff --git a/Configuration.storekit b/Configuration.storekit deleted file mode 100644 index a3e76a1..0000000 --- a/Configuration.storekit +++ /dev/null @@ -1,139 +0,0 @@ -{ - "appPolicies" : { - "eula" : "", - "policies" : [ - { - "locale" : "en_US", - "policyText" : "", - "policyURL" : "" - } - ] - }, - "identifier" : "00CCEDCC", - "nonRenewingSubscriptions" : [ - - ], - "products" : [ - - ], - "settings" : { - "_askToBuyEnabled" : false, - "_billingGracePeriodEnabled" : false, - "_billingIssuesEnabled" : false, - "_disableDialogs" : false, - "_failTransactionsEnabled" : false, - "_locale" : "en_US", - "_renewalBillingIssuesEnabled" : false, - "_storefront" : "USA", - "_storeKitErrors" : [ - { - "enabled" : false, - "name" : "Load Products" - }, - { - "enabled" : false, - "name" : "Purchase" - }, - { - "enabled" : false, - "name" : "Verification" - }, - { - "enabled" : false, - "name" : "App Store Sync" - }, - { - "enabled" : false, - "name" : "Subscription Status" - }, - { - "enabled" : false, - "name" : "App Transaction" - }, - { - "enabled" : false, - "name" : "Manage Subscriptions Sheet" - }, - { - "enabled" : false, - "name" : "Refund Request Sheet" - }, - { - "enabled" : false, - "name" : "Offer Code Redeem Sheet" - } - ], - "_timeRate" : 0 - }, - "subscriptionGroups" : [ - { - "id" : "21914363", - "localizations" : [ - - ], - "name" : "group1", - "subscriptions" : [ - { - "adHocOffers" : [ - - ], - "codeOffers" : [ - - ], - "displayPrice" : "0.99", - "familyShareable" : false, - "groupNumber" : 1, - "internalID" : "C011E06B", - "introductoryOffer" : null, - "localizations" : [ - { - "description" : "Flexible month-to-month billing", - "displayName" : "Monthly", - "locale" : "en_US" - } - ], - "productID" : "com.88oakapps.feels.IAP.subscriptions.monthly", - "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "Monthly", - "subscriptionGroupID" : "21914363", - "type" : "RecurringSubscription", - "winbackOffers" : [ - - ] - }, - { - "adHocOffers" : [ - - ], - "codeOffers" : [ - - ], - "displayPrice" : "9.99", - "familyShareable" : false, - "groupNumber" : 1, - "internalID" : "32967821", - "introductoryOffer" : null, - "localizations" : [ - { - "description" : "Best value — save over 15%", - "displayName" : "Yearly", - "locale" : "en_US" - } - ], - "productID" : "com.88oakapps.feels.IAP.subscriptions.yearly", - "recurringSubscriptionPeriod" : "P1Y", - "referenceName" : "Yearly", - "subscriptionGroupID" : "21914363", - "type" : "RecurringSubscription", - "winbackOffers" : [ - - ] - } - ] - } - ], - "version" : { - "major" : 4, - "minor" : 0 - } -} diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index 60bf02b..8f1fd3a 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -130,6 +130,7 @@ class IAPManager: ObservableObject { // MARK: - Initialization init() { + restoreCachedSubscriptionState() updateListenerTask = listenForTransactions() Task { @@ -167,12 +168,25 @@ class IAPManager: ObservableObject { let hasActiveSubscription = await checkForActiveSubscription() if hasActiveSubscription { - // State already set in checkForActiveSubscription + // State already set in checkForActiveSubscription — cache it + if case .subscribed(let expiration, _) = state { + cacheSubscriptionExpiration(expiration) + } syncSubscriptionStatusToUserDefaults() return } - // No active subscription - check trial status + // 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() } @@ -182,6 +196,27 @@ class IAPManager: ObservableObject { 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 { diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index 3ff2adb..4b64982 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -196,6 +196,7 @@ class UserDefaultsStore { case daysFilter case firstLaunchDate case hasActiveSubscription + case cachedSubscriptionExpiration case lastVotedDate case votingLayoutStyle case dayViewStyle