import Foundation import SwiftUI enum ConnectionStatus: Equatable { case disconnected case connecting case connected case reconnecting } @Observable final class AppState { var isLoggedIn = false var currentUser: User? var connectionStatus: ConnectionStatus = .disconnected var email: String = "" let chatClient = ChatClient() private var reconnectTask: Task? private var notificationTask: Task? private var isReconnecting = false private var backgroundedAt: Date? /// Start listening for connection state changes (call after login) func startConnectionMonitor() { notificationTask?.cancel() notificationTask = Task { [weak self] in guard let self else { return } let stream = await chatClient.makeNotificationStream() for await notification in stream { guard !Task.isCancelled else { break } if case .connectionStateChanged(let connected) = notification { await MainActor.run { if connected { self.connectionStatus = .connected self.isReconnecting = false self.reconnectTask?.cancel() self.reconnectTask = nil } else if self.isLoggedIn, !self.isReconnecting { // Only start reconnect if not already reconnecting // (reconnect() internally calls disconnect() which fires this) self.connectionStatus = .disconnected self.attemptReconnect() } } } } } } /// Attempt reconnect with exponential backoff; immediate logout on auth failure @MainActor private func attemptReconnect() { reconnectTask?.cancel() isReconnecting = true reconnectTask = Task { [weak self] in guard let self else { return } let maxAttempts = 5 var delay: TimeInterval = Constants.reconnectBaseDelay for attempt in 1...maxAttempts { guard !Task.isCancelled, self.isLoggedIn else { return } self.connectionStatus = .reconnecting #if DEBUG print("DEBUG AppState: reconnect attempt \(attempt)/\(maxAttempts), delay=\(delay)s") #endif try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard !Task.isCancelled, self.isLoggedIn else { return } let result = await self.chatClient.reconnect() switch result { case .success: self.connectionStatus = .connected self.isReconnecting = false #if DEBUG print("DEBUG AppState: reconnected on attempt \(attempt)") #endif return case .authFailed: // Keys rotated or invalid — logout immediately, don't retry self.isReconnecting = false #if DEBUG print("DEBUG AppState: auth failed (keys likely rotated), logging out immediately") #endif await self.logout() return case .networkError: // Network issue — retry with backoff delay = min(delay * 2, Constants.reconnectMaxDelay) } } // All network retries exhausted → force logout self.isReconnecting = false guard !Task.isCancelled, self.isLoggedIn else { return } #if DEBUG print("DEBUG AppState: reconnect failed after \(maxAttempts) attempts, logging out") #endif await self.logout() } } // MARK: - App Lifecycle func handleEnteredBackground() { backgroundedAt = Date() } @MainActor func handleBecameActive() { guard isLoggedIn, !isReconnecting else { return } let wasInBackground = backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0 backgroundedAt = nil Task { let alive = await chatClient.isConnectionAlive() if !alive { #if DEBUG print("DEBUG AppState: foreground — connection dead, reconnecting") #endif await MainActor.run { guard !self.isReconnecting else { return } self.connectionStatus = .reconnecting self.attemptReconnect() } } else if wasInBackground > 30 { // Connection appears alive but was backgrounded a long time — // force reconnect to ensure fresh state #if DEBUG print("DEBUG AppState: foreground — stale connection (\(Int(wasInBackground))s), reconnecting") #endif await MainActor.run { guard !self.isReconnecting else { return } self.connectionStatus = .reconnecting self.attemptReconnect() } } else { #if DEBUG print("DEBUG AppState: foreground — connection alive (\(Int(wasInBackground))s in bg)") #endif } } } func logout() async { isReconnecting = false reconnectTask?.cancel() reconnectTask = nil notificationTask?.cancel() notificationTask = nil await chatClient.disconnect() KeychainService.deleteCredentials() isLoggedIn = false currentUser = nil connectionStatus = .disconnected email = "" } }