diff --git a/Reflect.xcodeproj/project.pbxproj b/Reflect.xcodeproj/project.pbxproj index 548129d..722383b 100644 --- a/Reflect.xcodeproj/project.pbxproj +++ b/Reflect.xcodeproj/project.pbxproj @@ -928,7 +928,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Reflect Watch App/Reflect Watch AppDebug.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QND55P4443; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -940,7 +940,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect.debug.watch; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1084,7 +1084,7 @@ CODE_SIGN_ENTITLEMENTS = "Reflect (iOS)Dev.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QND55P4443; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1106,7 +1106,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect.debug; PRODUCT_NAME = Reflect; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1126,7 +1126,7 @@ CODE_SIGN_ENTITLEMENTS = "Reflect (iOS).entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QND55P4443; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1148,7 +1148,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect; PRODUCT_NAME = Reflect; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1169,7 +1169,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1182,7 +1182,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect; PRODUCT_NAME = Reflect; SDKROOT = macosx; @@ -1200,7 +1200,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1213,7 +1213,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect.debug; PRODUCT_NAME = Reflect; SDKROOT = macosx; @@ -1226,10 +1226,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1244,10 +1244,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1263,11 +1263,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-macOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; @@ -1281,11 +1281,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-macOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; @@ -1303,7 +1303,7 @@ CODE_SIGN_ENTITLEMENTS = ReflectWidgetExtensionDev.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QND55P4443; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ReflectWidgetExtension-Info.plist"; @@ -1316,7 +1316,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect.debug.widget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1337,7 +1337,7 @@ CODE_SIGN_ENTITLEMENTS = ReflectWidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QND55P4443; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ReflectWidgetExtension-Info.plist"; @@ -1350,7 +1350,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect.widget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1389,7 +1389,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Reflect Watch App/Reflect Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QND55P4443; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1401,7 +1401,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.reflect.watch; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index a2dbef0..073a4df 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -159,11 +159,16 @@ class IAPManager: ObservableObject { /// Check subscription status - call on app launch and when becoming active /// Throttled to avoid excessive StoreKit calls on rapid foreground transitions - func checkSubscriptionStatus() async { - // Throttle: skip if we checked recently (unless state is unknown) - if state != .unknown, + /// Pass `force: true` to bypass throttle (e.g. after a transaction update) + func checkSubscriptionStatus(force: Bool = false) async { + AppLogger.iap.debug("checkSubscriptionStatus: called (force=\(force), currentState=\(String(describing: self.state)))") + + // Throttle: skip if we checked recently (unless state is unknown or forced) + if !force, + state != .unknown, let lastCheck = lastStatusCheckTime, Date().timeIntervalSince(lastCheck) < statusCheckInterval { + AppLogger.iap.debug("checkSubscriptionStatus: THROTTLED (last check \(Date().timeIntervalSince(lastCheck))s ago)") return } @@ -172,6 +177,7 @@ class IAPManager: ObservableObject { defer { if isLoading { isLoading = false } lastStatusCheckTime = Date() + AppLogger.iap.debug("checkSubscriptionStatus: DONE — final state=\(String(describing: self.state))") } // Fetch available products @@ -179,6 +185,7 @@ class IAPManager: ObservableObject { // Check for active subscription let hasActiveSubscription = await checkForActiveSubscription() + AppLogger.iap.debug("checkSubscriptionStatus: hasActiveSubscription=\(hasActiveSubscription), state after check=\(String(describing: self.state))") if hasActiveSubscription { // State already set in checkForActiveSubscription — cache it @@ -195,29 +202,24 @@ class IAPManager: ObservableObject { return } - // Preserve terminal StoreKit states (expired/revoked) instead of overriding with trial fallback. - if case .expired = state { - syncSubscriptionStatusToUserDefaults() - trackSubscriptionAnalytics(source: "status_check_terminal") - return - } - if case .revoked = state { - syncSubscriptionStatusToUserDefaults() - trackSubscriptionAnalytics(source: "status_check_terminal") - return - } - // 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() - trackSubscriptionAnalytics(source: "status_check_cached") - return + // Only trust cached expiration if we're offline (products failed to load). + // If products loaded successfully, StoreKit had server access and the live result is authoritative. + if availableProducts.isEmpty { + let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date + AppLogger.iap.debug("checkSubscriptionStatus: offline (no products) — cachedExpiration=\(cachedExpiration?.description ?? "nil")") + + if let expiration = cachedExpiration, expiration > Date() { + AppLogger.iap.debug("checkSubscriptionStatus: using cached expiration (offline, still valid)") + state = .subscribed(expirationDate: expiration, willAutoRenew: false) + syncSubscriptionStatusToUserDefaults() + trackSubscriptionAnalytics(source: "status_check_cached") + return + } } // Subscription genuinely gone — clear cache and fall back to trial + AppLogger.iap.debug("checkSubscriptionStatus: falling back to trial — firstLaunchDate=\(self.firstLaunchDate), trialDays=\(self.trialDays)") cacheSubscriptionExpiration(nil) updateTrialState() trackSubscriptionAnalytics(source: "status_check_fallback") @@ -237,16 +239,24 @@ class IAPManager: ObservableObject { /// 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 + AppLogger.iap.debug("restoreCachedSubscriptionState: hasActive=\(hasActive), cachedExpiration=\(cachedExpiration?.description ?? "nil")") + + guard hasActive else { + AppLogger.iap.debug("restoreCachedSubscriptionState: no cached active state, skipping") + return + } // If we have a cached expiration and it's still in the future, restore subscribed state if let expiration = cachedExpiration, expiration > Date() { + AppLogger.iap.debug("restoreCachedSubscriptionState: restoring .subscribed (cached expiration in future)") 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 + AppLogger.iap.debug("restoreCachedSubscriptionState: restoring .subscribed (no expiration, trusting hasActive)") state = .subscribed(expirationDate: nil, willAutoRenew: false) + } else { + AppLogger.iap.debug("restoreCachedSubscriptionState: cached expiration in past, not restoring") } } @@ -254,7 +264,7 @@ class IAPManager: ObservableObject { func restore(source: String = "settings") async { do { try await AppStore.sync() - await checkSubscriptionStatus() + await checkSubscriptionStatus(force: true) AnalyticsManager.shared.trackPurchaseRestored(source: source) trackSubscriptionAnalytics(source: "restore") } catch { @@ -377,10 +387,13 @@ class IAPManager: ObservableObject { private func updateTrialState() { let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 let daysRemaining = trialDays - daysSinceInstall + AppLogger.iap.debug("updateTrialState: firstLaunchDate=\(self.firstLaunchDate), daysSinceInstall=\(daysSinceInstall), daysRemaining=\(daysRemaining)") if daysRemaining > 0 { + AppLogger.iap.debug("updateTrialState: setting .inTrial(daysRemaining: \(daysRemaining))") state = .inTrial(daysRemaining: daysRemaining) } else { + AppLogger.iap.debug("updateTrialState: setting .trialExpired") state = .trialExpired } @@ -499,7 +512,7 @@ class IAPManager: ObservableObject { guard case .verified(let transaction) = result else { continue } await transaction.finish() - await self?.checkSubscriptionStatus() + await self?.checkSubscriptionStatus(force: true) } } } diff --git a/Shared/Views/ReflectSubscriptionStoreView.swift b/Shared/Views/ReflectSubscriptionStoreView.swift index 9b4ad8e..0723a8a 100644 --- a/Shared/Views/ReflectSubscriptionStoreView.swift +++ b/Shared/Views/ReflectSubscriptionStoreView.swift @@ -71,7 +71,7 @@ struct ReflectSubscriptionStoreView: View { case .success(.success(_)): AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source) Task { @MainActor in - await iapManager.checkSubscriptionStatus() + await iapManager.checkSubscriptionStatus(force: true) iapManager.trackSubscriptionAnalytics(source: "purchase_success") } dismiss()