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>
This commit is contained in:
Trey t
2026-02-10 09:56:14 -06:00
parent 65460c63b3
commit 1e8acfa320
3 changed files with 38 additions and 141 deletions

View File

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

View File

@@ -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 {

View File

@@ -196,6 +196,7 @@ class UserDefaultsStore {
case daysFilter
case firstLaunchDate
case hasActiveSubscription
case cachedSubscriptionExpiration
case lastVotedDate
case votingLayoutStyle
case dayViewStyle