Files
Kecalek_python/ios_client 0.8.5/Kecalek/AppState.swift
2026-03-14 12:43:56 +01:00

162 lines
5.8 KiB
Swift

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<Void, Never>?
private var notificationTask: Task<Void, Never>?
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 = ""
}
}