From 55de7d16459d4a5e241ebdb5f87cded3e2b7e9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Wed, 11 Feb 2026 11:33:11 +0100 Subject: [PATCH] Improve iCloud / Watch token sync & recovery Add robust handling for token synchronization and recovery across iOS/watchOS and Flutter. - iOS (WatchSessionManager.swift): queue token events until Flutter signals watchSyncReady, flush queued auth events and iCloud recovery notifications when ready, forward tokens with de-duplication logic, provide fallback to iCloud when Flutter isn't ready, and avoid sending duplicate events. Adds helper methods for token payloads and comparison. - Token management (TokenManager.swift): track active studentIdNorm in UserDefaults to prefer a single account, persist active account on saves, prefer freshest token within preferred account, improve proactive refresh with configurable lead time and cooldown, skip unnecessary recoveries when a valid token exists, and add cooldown for phone recovery requests. - iCloud logic (iCloudTokenManager.swift): avoid overwriting tokens across accounts using updatedAt comparisons and only ignore truly stale saves. - Dart client (kreta_client.dart): skip recovery if local token still valid, clearer iCloud recovery retry flow, fallbacks, and clear reauth flag when usable token applied. - Token model (token_model.dart & generated .g.dart): add tokenVersion and updatedAtMs fields, populate them on creation/from response, and update Isar schema + generated query helpers. - Watch sync helper (watch_sync_helper.dart): include tokenVersion/updatedAt in outgoing payloads, resolve incoming/current versions more robustly, use updatedAt when deciding freshness, and invoke watchSyncReady on init so native side can flush queued events. - App init (main.dart): run an iCloud recovery check on iOS during startup and only clear reauth flag when the chosen token is still in the future. These changes improve cross-device token consistency, reduce spurious reauth prompts, and make proactive refresh/recovery less noisy and more efficient. --- firka/ios/Runner/WatchSessionManager.swift | 154 ++++++++++-- firka/ios/Shared/API/TokenManager.swift | 163 +++++++++--- firka/ios/Shared/API/iCloudTokenManager.swift | 27 +- .../lib/helpers/api/client/kreta_client.dart | 51 +++- firka/lib/helpers/db/models/token_model.dart | 27 +- .../lib/helpers/db/models/token_model.g.dart | 238 ++++++++++++++++++ firka/lib/helpers/watch_sync_helper.dart | 66 ++++- firka/lib/main.dart | 20 +- 8 files changed, 657 insertions(+), 89 deletions(-) diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift index 7703b8b..6f12030 100644 --- a/firka/ios/Runner/WatchSessionManager.swift +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -6,6 +6,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate { static let shared = WatchSessionManager() private var flutterChannel: FlutterMethodChannel? + private var isFlutterWatchSyncReady = false + private var pendingAuthPayloads: [[String: Any]] = [] + private var pendingICloudRecoveryNotification = false override private init() { super.init() @@ -33,6 +36,8 @@ class WatchSessionManager: NSObject, WCSessionDelegate { self?.handleCheckiCloudToken(result: result) case "saveTokeToniCloud": self?.handleSaveTokenToiCloud(arguments: call.arguments, result: result) + case "watchSyncReady": + self?.handleWatchSyncReady(result: result) default: result(FlutterMethodNotImplemented) } @@ -56,7 +61,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate { @objc private func handleTokenRecoveredFromiCloud() { print("[WatchSessionManager] Token recovered from iCloud, notifying Flutter to clear reauth flag") - flutterChannel?.invokeMethod("onTokenRecoveredFromiCloud", arguments: nil) + DispatchQueue.main.async { + self.notifyTokenRecoveredToFlutter() + } } private func parseInt64(_ value: Any?) -> Int64? { @@ -75,6 +82,90 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return nil } + private func tokenPayload(from token: WatchToken) -> [String: Any] { + var tokenData: [String: Any] = [ + "studentId": token.studentId, + "studentIdNorm": token.studentIdNorm, + "iss": token.iss, + "idToken": token.idToken, + "accessToken": token.accessToken, + "refreshToken": token.refreshToken, + "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) + ] + if let tokenVersion = token.effectiveTokenVersion { + tokenData["tokenVersion"] = tokenVersion + } + if let updatedAtMs = token.effectiveUpdatedAtMs { + tokenData["updatedAtMs"] = updatedAtMs + } + return tokenData + } + + private func fallbackTokenFromiCloud() -> [String: Any]? { + guard let token = iCloudTokenManager.shared.loadToken() else { + return nil + } + return tokenPayload(from: token) + } + + private func sameTokenPayload(_ lhs: [String: Any], _ rhs: [String: Any]) -> Bool { + return parseInt64(lhs["studentIdNorm"]) == parseInt64(rhs["studentIdNorm"]) && + parseInt64(lhs["expiryDate"]) == parseInt64(rhs["expiryDate"]) && + parseInt64(lhs["tokenVersion"]) == parseInt64(rhs["tokenVersion"]) && + parseInt64(lhs["updatedAtMs"]) == parseInt64(rhs["updatedAtMs"]) && + (lhs["refreshToken"] as? String) == (rhs["refreshToken"] as? String) + } + + private func enqueuePendingAuth(_ authData: [String: Any]) { + if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) { + return + } + pendingAuthPayloads.append(authData) + print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready") + } + + private func forwardTokenToFlutter(_ authData: [String: Any]) { + guard isFlutterWatchSyncReady else { + enqueuePendingAuth(authData) + return + } + flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + } + + private func notifyTokenRecoveredToFlutter() { + guard isFlutterWatchSyncReady else { + pendingICloudRecoveryNotification = true + print("[WatchSessionManager] Queued iCloud recovery notification until Flutter sync is ready") + return + } + flutterChannel?.invokeMethod("onTokenRecoveredFromiCloud", arguments: nil) + } + + private func flushPendingEvents() { + guard isFlutterWatchSyncReady else { + return + } + if !pendingAuthPayloads.isEmpty { + print("[WatchSessionManager] Flushing \(pendingAuthPayloads.count) queued token event(s) to Flutter") + } + for authData in pendingAuthPayloads { + flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + } + pendingAuthPayloads.removeAll() + + if pendingICloudRecoveryNotification { + pendingICloudRecoveryNotification = false + flutterChannel?.invokeMethod("onTokenRecoveredFromiCloud", arguments: nil) + } + } + + private func handleWatchSyncReady(result: @escaping FlutterResult) { + isFlutterWatchSyncReady = true + print("[WatchSessionManager] Flutter WatchSync marked as ready") + flushPendingEvents() + result(nil) + } + private func handleSendTokenToWatch(arguments: Any?, result: @escaping FlutterResult) { guard let authData = arguments as? [String: Any] else { result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil)) @@ -196,23 +287,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { formatter.dateFormat = "HH:mm:ss" print("[WatchSessionManager] Found iCloud token, expiry: \(formatter.string(from: token.expiryDate))") - var tokenData: [String: Any] = [ - "studentId": token.studentId, - "studentIdNorm": token.studentIdNorm, - "iss": token.iss, - "idToken": token.idToken, - "accessToken": token.accessToken, - "refreshToken": token.refreshToken, - "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) - ] - if let tokenVersion = token.effectiveTokenVersion { - tokenData["tokenVersion"] = tokenVersion - } - if let updatedAtMs = token.effectiveUpdatedAtMs { - tokenData["updatedAtMs"] = updatedAtMs - } - - result(tokenData) + result(tokenPayload(from: token)) } private func handleSaveTokenToiCloud(arguments: Any?, result: @escaping FlutterResult) { @@ -272,7 +347,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { let context = session.receivedApplicationContext if let authData = context["auth"] as? [String: Any] { print("[WatchSessionManager] Found pending auth in applicationContext") - self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + self.forwardTokenToFlutter(authData) } } } @@ -284,7 +359,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { DispatchQueue.main.async { if let authData = applicationContext["auth"] as? [String: Any] { print("[WatchSessionManager] Processing auth from applicationContext") - self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + self.forwardTokenToFlutter(authData) } } } @@ -303,19 +378,39 @@ class WatchSessionManager: NSObject, WCSessionDelegate { switch action { case "requestToken": + if !self.isFlutterWatchSyncReady { + if let tokenData = self.fallbackTokenFromiCloud() { + print("[WatchSessionManager] Flutter not ready, returning iCloud token to Watch") + replyHandler(["auth": tokenData]) + } else { + print("[WatchSessionManager] Flutter not ready and no iCloud token available") + replyHandler(["error": "no_token"]) + } + return + } DispatchQueue.main.async { self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in if let tokenData = result as? [String: Any] { if let error = tokenData["error"] as? String { - print("[WatchSessionManager] Flutter returned error: \(error)") - replyHandler(["error": error]) + if let fallbackToken = self.fallbackTokenFromiCloud() { + print("[WatchSessionManager] Flutter returned error (\(error)), falling back to iCloud token") + replyHandler(["auth": fallbackToken]) + } else { + print("[WatchSessionManager] Flutter returned error: \(error)") + replyHandler(["error": error]) + } } else { print("[WatchSessionManager] Sending token to Watch") replyHandler(["auth": tokenData]) } } else { - print("[WatchSessionManager] No token available from Flutter") - replyHandler(["error": "no_token"]) + if let fallbackToken = self.fallbackTokenFromiCloud() { + print("[WatchSessionManager] No Flutter token available, falling back to iCloud token") + replyHandler(["auth": fallbackToken]) + } else { + print("[WatchSessionManager] No token available from Flutter") + replyHandler(["error": "no_token"]) + } } } } @@ -339,6 +434,15 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return } + if !self.isFlutterWatchSyncReady { + print("[WatchSessionManager] Flutter not ready, queueing token from Watch") + DispatchQueue.main.async { + self.enqueuePendingAuth(tokenData) + } + replyHandler(["success": true]) + return + } + print("[WatchSessionManager] Receiving token from Watch") DispatchQueue.main.async { self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in @@ -383,7 +487,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { if messageId == "token_update_from_watch" { if let authData = userInfo["auth"] as? [String: Any] { - self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + self.forwardTokenToFlutter(authData) print("[WatchSessionManager] Token received from Watch") } } diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift index 63dce64..34c0f43 100644 --- a/firka/ios/Shared/API/TokenManager.swift +++ b/firka/ios/Shared/API/TokenManager.swift @@ -40,6 +40,9 @@ class TokenManager { private let tokenRefreshURL = "https://idp.e-kreta.hu/connect/token" private let clientID = "kreta-ellenorzo-student-mobile-ios" private let userAgent = "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0" + private let activeStudentIdNormKey = "firka.active_student_id_norm" + private let proactiveRefreshLeadTime: TimeInterval = 5 * 60 + private let minimumProactiveRefreshInterval: TimeInterval = 60 #if os(iOS) private let deviceName = "iPhone" @@ -48,6 +51,10 @@ class TokenManager { #endif private let recoveryLock = NSLock() private var recoveryInProgress = false + private var lastProactiveRefreshAttemptAt: Date? + #if os(watchOS) + private var lastPhoneRecoveryRequestAt: Date? + #endif private func startRecoveryIfNeeded() -> Bool { recoveryLock.lock() @@ -71,26 +78,62 @@ class TokenManager { return recoveryInProgress } + private func getActiveStudentIdNorm() -> Int64? { + if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Int64 { + return value + } + if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Int { + return Int64(value) + } + if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Double { + return Int64(value) + } + if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? String, + let parsed = Int64(value) { + return parsed + } + return nil + } + + private func setActiveStudentIdNorm(_ studentIdNorm: Int64) { + UserDefaults.standard.set(studentIdNorm, forKey: activeStudentIdNormKey) + } + private init() { iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in guard let self = self else { return } + let preferredStudentIdNorm = self.getActiveStudentIdNorm() + if let preferredStudentIdNorm, + iCloudToken.studentIdNorm != preferredStudentIdNorm { + print("[TokenManager] Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)") + return + } + let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60) let keychainToken = self.loadTokenFromKeychain() let fileToken = self.loadTokenFromFile() - let localToken: WatchToken? = { - if let k = keychainToken, let f = fileToken { - return k.isNewer(than: f) ? k : f + var localCandidates: [WatchToken] = [] + if let keychainToken { localCandidates.append(keychainToken) } + if let fileToken { localCandidates.append(fileToken) } + if let preferredStudentIdNorm { + let filtered = localCandidates.filter { $0.studentIdNorm == preferredStudentIdNorm } + if !filtered.isEmpty { + localCandidates = filtered } - return keychainToken ?? fileToken - }() + } + let localToken = localCandidates.dropFirst().reduce(localCandidates.first) { best, candidate in + guard let best else { return candidate } + return candidate.isNewer(than: best) ? candidate : best + } if let localToken = localToken { if iCloudToken.isNewer(than: localToken) { print("[TokenManager] iCloud token is fresher, updating local cache") try? self.saveTokenToKeychain(iCloudToken) try? self.saveTokenToFile(iCloudToken) + self.setActiveStudentIdNorm(iCloudToken.studentIdNorm) #if os(watchOS) DataStore.shared.checkTokenState() @@ -108,6 +151,7 @@ class TokenManager { print("[TokenManager] No local token, using iCloud token") try? self.saveTokenToKeychain(iCloudToken) try? self.saveTokenToFile(iCloudToken) + self.setActiveStudentIdNorm(iCloudToken.studentIdNorm) #if os(watchOS) DataStore.shared.checkTokenState() @@ -158,22 +202,39 @@ class TokenManager { return nil } - let freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in + let preferredStudentIdNorm = getActiveStudentIdNorm() + let preferredCandidates: [(token: WatchToken, source: String)] + if let preferredStudentIdNorm { + let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm } + preferredCandidates = filtered.isEmpty ? candidates : filtered + if filtered.isEmpty { + print("[TokenManager] Active account token not found locally, falling back to freshest available account") + } + } else { + preferredCandidates = candidates + } + + let freshest = preferredCandidates.dropFirst().reduce(preferredCandidates[0]) { currentBest, candidate in candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest } + setActiveStudentIdNorm(freshest.token.studentIdNorm) let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" formatter.timeZone = TimeZone.current - print("[TokenManager] Token sources found: \(candidates.map { "\($0.source): \(formatter.string(from: $0.token.expiryDate))" }.joined(separator: ", "))") + print("[TokenManager] Token sources found: \(candidates.map { "\($0.source): \($0.token.studentIdNorm) @ \(formatter.string(from: $0.token.expiryDate))" }.joined(separator: ", "))") print("[TokenManager] Using freshest token from \(freshest.source) (expiry: \(formatter.string(from: freshest.token.expiryDate)))") - if keychainToken == nil || freshest.token.isNewer(than: keychainToken!) { + if keychainToken == nil || + keychainToken!.studentIdNorm != freshest.token.studentIdNorm || + freshest.token.isNewer(than: keychainToken!) { print("[TokenManager] Syncing fresher token to keychain") try? saveTokenToKeychain(freshest.token) } - if fileToken == nil || freshest.token.isNewer(than: fileToken!) { + if fileToken == nil || + fileToken!.studentIdNorm != freshest.token.studentIdNorm || + freshest.token.isNewer(than: fileToken!) { print("[TokenManager] Syncing fresher token to file") try? saveTokenToFile(freshest.token) } @@ -201,6 +262,7 @@ class TokenManager { print("[TokenManager] Deleting token from all storage locations") deleteTokenFromKeychain() iCloudTokenManager.shared.deleteToken() + UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey) guard let filePath = getTokenFilePath() else { return } try? FileManager.default.removeItem(at: filePath) @@ -214,6 +276,7 @@ class TokenManager { } print("[TokenManager] Saving token locally (Keychain + file)") + setActiveStudentIdNorm(token.studentIdNorm) try saveTokenToKeychain(token) @@ -327,24 +390,35 @@ class TokenManager { return false } - let proactiveThreshold = token.expiryDate.addingTimeInterval(-12 * 3600) + let proactiveThreshold = token.expiryDate.addingTimeInterval(-proactiveRefreshLeadTime) return Date() >= proactiveThreshold } func refreshTokenProactively() async { - guard let token = loadToken() else { + guard loadToken() != nil else { print("[TokenManager] No token available for proactive refresh") return } - let proactiveThreshold = token.expiryDate.addingTimeInterval(-12 * 3600) - guard Date() >= proactiveThreshold else { - print("[TokenManager] Token still valid, no proactive refresh needed") + guard shouldRefreshProactively() else { + print("[TokenManager] Token is not close to expiry, no proactive refresh needed") return } + let now = Date() + if let lastAttempt = lastProactiveRefreshAttemptAt, + now.timeIntervalSince(lastAttempt) < minimumProactiveRefreshInterval { + print("[TokenManager] Proactive refresh skipped due to cooldown") + return + } + lastProactiveRefreshAttemptAt = now + print("[TokenManager] Proactively refreshing token...") do { + guard let token = loadToken() else { + print("[TokenManager] Token disappeared before proactive refresh") + return + } _ = try await refreshTokenInternal(token) print("[TokenManager] Proactive token refresh succeeded") } catch { @@ -354,6 +428,11 @@ class TokenManager { // MARK: - Central Token Recovery func recoverToken() async -> WatchToken? { + if let validToken = loadToken(), !isTokenExpired() { + print("[TokenManager] Existing token is valid, skipping recovery flow") + return validToken + } + if !startRecoveryIfNeeded() { print("[TokenManager] Recovery already in progress, waiting for current recovery...") for _ in 0..<40 { @@ -383,6 +462,10 @@ class TokenManager { print("[TokenManager] Step 1: Trying local token refresh...") if let token = loadToken() { + if token.expiryDate > Date().addingTimeInterval(60) { + print("[TokenManager] Step 1 SUCCESS: Local token already valid") + return token + } do { let refreshedToken = try await refreshTokenInternal(token) print("[TokenManager] Step 1 SUCCESS: Local refresh succeeded") @@ -396,12 +479,18 @@ class TokenManager { print("[TokenManager] Step 2: Checking Keychain and WatchConnectivity...") if let recoveredToken = await tryRecoverFromKeychainAndWatch() { - do { - let refreshedToken = try await refreshTokenInternal(recoveredToken) - print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token refresh succeeded") - return refreshedToken - } catch { - print("[TokenManager] Step 2 FAILED: Keychain/Watch token refresh failed: \(error)") + if recoveredToken.expiryDate > Date().addingTimeInterval(60) { + print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token is already valid") + try? saveToken(recoveredToken, syncToICloud: false) + return recoveredToken + } else { + do { + let refreshedToken = try await refreshTokenInternal(recoveredToken) + print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token refresh succeeded") + return refreshedToken + } catch { + print("[TokenManager] Step 2 FAILED: Keychain/Watch token refresh failed: \(error)") + } } } else { print("[TokenManager] Step 2 SKIPPED: No token from Keychain/Watch") @@ -424,16 +513,16 @@ class TokenManager { print("[TokenManager] Step 3: iCloud attempt \(attempt + 1)/\(retryDelays.count)...") if let iCloudToken = iCloudTokenManager.shared.loadToken() { + if let preferredStudentIdNorm = getActiveStudentIdNorm(), + iCloudToken.studentIdNorm != preferredStudentIdNorm { + print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)") + continue + } iCloudHasToken = true if iCloudToken.expiryDate > Date() { - print("[TokenManager] Step 3: Found valid iCloud token, trying refresh...") - do { - let refreshedToken = try await refreshTokenInternal(iCloudToken) - print("[TokenManager] Step 3 SUCCESS: iCloud token refresh succeeded on attempt \(attempt + 1)") - return refreshedToken - } catch { - print("[TokenManager] Step 3: iCloud token refresh failed on attempt \(attempt + 1): \(error)") - } + print("[TokenManager] Step 3 SUCCESS: Found valid iCloud token, applying without immediate refresh") + try? saveToken(iCloudToken, syncToICloud: false) + return iCloudToken } else { print("[TokenManager] Step 3: iCloud token is expired, trying refresh anyway...") do { @@ -480,6 +569,16 @@ class TokenManager { return nil } + if let preferredStudentIdNorm = getActiveStudentIdNorm() { + let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm } + if !filtered.isEmpty { + candidates = filtered + } else { + print("[TokenManager] No recovery candidate for active account \(preferredStudentIdNorm)") + return nil + } + } + let freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest } @@ -495,6 +594,14 @@ class TokenManager { return nil } + let now = Date() + if let lastPhoneRecoveryRequestAt, + now.timeIntervalSince(lastPhoneRecoveryRequestAt) < 5 { + print("[TokenManager] Skipping iPhone recovery request due to cooldown") + return nil + } + lastPhoneRecoveryRequestAt = now + let timeoutSeconds: UInt64 = 10 return await withTaskGroup(of: WatchToken?.self) { group in diff --git a/firka/ios/Shared/API/iCloudTokenManager.swift b/firka/ios/Shared/API/iCloudTokenManager.swift index d79dbb9..f31c9fd 100644 --- a/firka/ios/Shared/API/iCloudTokenManager.swift +++ b/firka/ios/Shared/API/iCloudTokenManager.swift @@ -41,14 +41,25 @@ class iCloudTokenManager { return } - if let existingToken = loadToken(), - existingToken.isSameAccount(as: token), - !token.isNewer(than: existingToken) { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - formatter.timeZone = TimeZone.current - print("[iCloud] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))") - return + if let existingToken = loadToken() { + if existingToken.isSameAccount(as: token) { + if !token.isNewer(than: existingToken) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + formatter.timeZone = TimeZone.current + print("[iCloud] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))") + return + } + } else { + let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0 + let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0 + if incomingUpdatedAt > 0 && + existingUpdatedAt > 0 && + incomingUpdatedAt <= existingUpdatedAt { + print("[iCloud] Ignoring cross-account stale token save from \(deviceName)") + return + } + } } print("[iCloud] Saving token to iCloud from \(deviceName)") diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index 57b83b6..e83403f 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -136,6 +136,15 @@ class KretaClient { Future recoverToken() async { logger.info("[Recovery] Starting central token recovery..."); + final now = timeNow(); + final localExpiry = model.expiryDate; + if (localExpiry != null && + localExpiry.isAfter(now.add(const Duration(seconds: 60)))) { + logger.info( + "[Recovery] Existing token is still valid, skipping recovery steps"); + clearReauthFlag(); + return true; + } logger.info("[Recovery] Step 1: Trying local token refresh..."); try { @@ -148,6 +157,7 @@ class KretaClient { model = tokenModel; await _syncTokenToAppleTargets(model); + clearReauthFlag(); logger.info("[Recovery] Step 1 SUCCESS: Local refresh succeeded"); return true; } catch (e) { @@ -155,13 +165,15 @@ class KretaClient { } if (!Platform.isIOS || !initDone) { - logger.warning("[Recovery] Not iOS or not initialized, cannot try iCloud"); + logger + .warning("[Recovery] Not iOS or not initialized, cannot try iCloud"); return false; } logger.info("[Recovery] Step 2: Trying iCloud recovery with retries..."); const retryDelays = [0, 5, 10, 5, 10]; // instant, 5s, 10s, 5s, 10s - bool iCloudHasToken = false; // Track if iCloud has any token (to avoid useless retries) + bool iCloudHasToken = + false; // Track if iCloud has any token (to avoid useless retries) for (var attempt = 0; attempt < retryDelays.length; attempt++) { final delay = retryDelays[attempt]; @@ -170,11 +182,13 @@ class KretaClient { logger.info("[Recovery] Skipping retries - iCloud has no token"); break; } - logger.info("[Recovery] Waiting ${delay}s before attempt ${attempt + 1}..."); + logger.info( + "[Recovery] Waiting ${delay}s before attempt ${attempt + 1}..."); await Future.delayed(Duration(seconds: delay)); } - logger.info("[Recovery] iCloud attempt ${attempt + 1}/${retryDelays.length}..."); + logger.info( + "[Recovery] iCloud attempt ${attempt + 1}/${retryDelays.length}..."); final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud( isar: isar, @@ -184,9 +198,21 @@ class KretaClient { if (recovered) { iCloudHasToken = true; - await _reloadActiveTokenModel(preferredStudentIdNorm: model.studentIdNorm); + await _reloadActiveTokenModel( + preferredStudentIdNorm: model.studentIdNorm); - logger.info("[Recovery] Found iCloud token, trying refresh..."); + final recoveredExpiry = model.expiryDate; + if (recoveredExpiry != null && + recoveredExpiry + .isAfter(timeNow().add(const Duration(seconds: 60)))) { + logger.info( + "[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}: usable iCloud token applied without immediate refresh"); + clearReauthFlag(); + return true; + } + + logger.info( + "[Recovery] Found iCloud token close to expiry, trying refresh..."); try { var extended = await extendToken(model); var tokenModel = TokenModel.fromResp(extended); @@ -197,14 +223,17 @@ class KretaClient { model = tokenModel; await _syncTokenToAppleTargets(model); + clearReauthFlag(); logger.info("[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}"); return true; } catch (e) { - logger.warning("[Recovery] iCloud token refresh failed on attempt ${attempt + 1}: $e"); + logger.warning( + "[Recovery] iCloud token refresh failed on attempt ${attempt + 1}: $e"); iCloudHasToken = true; } } else { - logger.info("[Recovery] No fresh token in iCloud on attempt ${attempt + 1}"); + logger.info( + "[Recovery] No fresh token in iCloud on attempt ${attempt + 1}"); if (attempt == 0) { iCloudHasToken = false; } @@ -221,7 +250,8 @@ class KretaClient { if (model.expiryDate == null || model.expiryDate!.isBefore(fiveMinutesFromNow)) { - logger.info("[Proactive] Token expired or expiring soon, starting recovery..."); + logger.info( + "[Proactive] Token expired or expiring soon, starting recovery..."); final recovered = await recoverToken(); if (recovered) { @@ -251,7 +281,8 @@ class KretaClient { while (_tokenMutex) { if (DateTime.now().difference(startTime) > maxWaitTime) { - logger.warning("[Mutex] Timeout waiting for token mutex, forcing release"); + logger.warning( + "[Mutex] Timeout waiting for token mutex, forcing release"); _tokenMutex = false; break; } diff --git a/firka/lib/helpers/db/models/token_model.dart b/firka/lib/helpers/db/models/token_model.dart index 366df6d..ebfdaa3 100644 --- a/firka/lib/helpers/db/models/token_model.dart +++ b/firka/lib/helpers/db/models/token_model.dart @@ -19,11 +19,22 @@ class TokenModel { String? accessToken; // The main auth token String? refreshToken; // Token used to refresh the access token DateTime? expiryDate; + int? tokenVersion; + int? updatedAtMs; TokenModel(); - factory TokenModel.fromValues(Id studentIdNorm, studentId, String iss, - String idToken, String accessToken, String refreshToken, int expiryDate) { + factory TokenModel.fromValues( + Id studentIdNorm, + studentId, + String iss, + String idToken, + String accessToken, + String refreshToken, + int expiryDate, { + int? tokenVersion, + int? updatedAtMs, + }) { var m = TokenModel(); m.studentIdNorm = studentIdNorm; @@ -33,6 +44,8 @@ class TokenModel { m.accessToken = accessToken; m.refreshToken = refreshToken; m.expiryDate = DateTime.fromMillisecondsSinceEpoch(expiryDate); + m.tokenVersion = tokenVersion; + m.updatedAtMs = updatedAtMs; return m; } @@ -67,6 +80,16 @@ class TokenModel { m.expiryDate = timeNow() .add(Duration(seconds: resp.expiresIn)) .subtract(Duration(minutes: 1)); // just to be safe + final iat = payload["iat"]; + if (iat is int) { + m.tokenVersion = iat * 1000; + } else if (iat is String) { + final parsed = int.tryParse(iat); + if (parsed != null) { + m.tokenVersion = parsed * 1000; + } + } + m.updatedAtMs = DateTime.now().millisecondsSinceEpoch; return m; } diff --git a/firka/lib/helpers/db/models/token_model.g.dart b/firka/lib/helpers/db/models/token_model.g.dart index 9765a4c..c700d47 100644 --- a/firka/lib/helpers/db/models/token_model.g.dart +++ b/firka/lib/helpers/db/models/token_model.g.dart @@ -46,6 +46,16 @@ const TokenModelSchema = CollectionSchema( id: 5, name: r'studentId', type: IsarType.string, + ), + r'tokenVersion': PropertySchema( + id: 6, + name: r'tokenVersion', + type: IsarType.long, + ), + r'updatedAtMs': PropertySchema( + id: 7, + name: r'updatedAtMs', + type: IsarType.long, ) }, estimateSize: _tokenModelEstimateSize, @@ -113,6 +123,8 @@ void _tokenModelSerialize( writer.writeString(offsets[3], object.iss); writer.writeString(offsets[4], object.refreshToken); writer.writeString(offsets[5], object.studentId); + writer.writeLong(offsets[6], object.tokenVersion); + writer.writeLong(offsets[7], object.updatedAtMs); } TokenModel _tokenModelDeserialize( @@ -129,6 +141,8 @@ TokenModel _tokenModelDeserialize( object.refreshToken = reader.readStringOrNull(offsets[4]); object.studentId = reader.readStringOrNull(offsets[5]); object.studentIdNorm = id; + object.tokenVersion = reader.readLongOrNull(offsets[6]); + object.updatedAtMs = reader.readLongOrNull(offsets[7]); return object; } @@ -151,6 +165,10 @@ P _tokenModelDeserializeProp

( return (reader.readStringOrNull(offset)) as P; case 5: return (reader.readStringOrNull(offset)) as P; + case 6: + return (reader.readLongOrNull(offset)) as P; + case 7: + return (reader.readLongOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -1153,6 +1171,154 @@ extension TokenModelQueryFilter )); }); } + + QueryBuilder + tokenVersionIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'tokenVersion', + )); + }); + } + + QueryBuilder + tokenVersionIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'tokenVersion', + )); + }); + } + + QueryBuilder + tokenVersionEqualTo(int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'tokenVersion', + value: value, + )); + }); + } + + QueryBuilder + tokenVersionGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'tokenVersion', + value: value, + )); + }); + } + + QueryBuilder + tokenVersionLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'tokenVersion', + value: value, + )); + }); + } + + QueryBuilder + tokenVersionBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'tokenVersion', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + updatedAtMsIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'updatedAtMs', + )); + }); + } + + QueryBuilder + updatedAtMsIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'updatedAtMs', + )); + }); + } + + QueryBuilder + updatedAtMsEqualTo(int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'updatedAtMs', + value: value, + )); + }); + } + + QueryBuilder + updatedAtMsGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'updatedAtMs', + value: value, + )); + }); + } + + QueryBuilder + updatedAtMsLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'updatedAtMs', + value: value, + )); + }); + } + + QueryBuilder + updatedAtMsBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'updatedAtMs', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } } extension TokenModelQueryObject @@ -1234,6 +1400,30 @@ extension TokenModelQuerySortBy return query.addSortBy(r'studentId', Sort.desc); }); } + + QueryBuilder sortByTokenVersion() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenVersion', Sort.asc); + }); + } + + QueryBuilder sortByTokenVersionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenVersion', Sort.desc); + }); + } + + QueryBuilder sortByUpdatedAtMs() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'updatedAtMs', Sort.asc); + }); + } + + QueryBuilder sortByUpdatedAtMsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'updatedAtMs', Sort.desc); + }); + } } extension TokenModelQuerySortThenBy @@ -1321,6 +1511,30 @@ extension TokenModelQuerySortThenBy return query.addSortBy(r'studentIdNorm', Sort.desc); }); } + + QueryBuilder thenByTokenVersion() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenVersion', Sort.asc); + }); + } + + QueryBuilder thenByTokenVersionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenVersion', Sort.desc); + }); + } + + QueryBuilder thenByUpdatedAtMs() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'updatedAtMs', Sort.asc); + }); + } + + QueryBuilder thenByUpdatedAtMsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'updatedAtMs', Sort.desc); + }); + } } extension TokenModelQueryWhereDistinct @@ -1365,6 +1579,18 @@ extension TokenModelQueryWhereDistinct return query.addDistinctBy(r'studentId', caseSensitive: caseSensitive); }); } + + QueryBuilder distinctByTokenVersion() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tokenVersion'); + }); + } + + QueryBuilder distinctByUpdatedAtMs() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'updatedAtMs'); + }); + } } extension TokenModelQueryProperty @@ -1410,4 +1636,16 @@ extension TokenModelQueryProperty return query.addPropertyName(r'studentId'); }); } + + QueryBuilder tokenVersionProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tokenVersion'); + }); + } + + QueryBuilder updatedAtMsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'updatedAtMs'); + }); + } } diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index 7d147f7..76b8270 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -96,16 +97,27 @@ class WatchSyncHelper { } } - static int _resolveTokenVersionForSend(TokenModel token) { - return _extractTokenVersionFromIdToken(token.idToken) ?? - DateTime.now().millisecondsSinceEpoch; - } - static int? _resolveIncomingTokenVersion(Map tokenData) { return _asInt(tokenData['tokenVersion']) ?? _extractTokenVersionFromIdToken(tokenData['idToken'] as String?); } + static int? _resolveTokenVersionForModel(TokenModel token) { + final tokenVersion = token.tokenVersion; + if (tokenVersion != null && tokenVersion > 0) { + return tokenVersion; + } + return _extractTokenVersionFromIdToken(token.idToken); + } + + static int? _resolveUpdatedAtForModel(TokenModel token) { + final updatedAtMs = token.updatedAtMs; + if (updatedAtMs != null && updatedAtMs > 0) { + return updatedAtMs; + } + return null; + } + static bool _isIncomingTokenNewerThanCurrent({ required DateTime incomingExpiry, required String? incomingIdToken, @@ -119,9 +131,10 @@ class WatchSyncHelper { return true; } - final incomingVersion = - incomingTokenVersion ?? _extractTokenVersionFromIdToken(incomingIdToken); - final currentVersion = _extractTokenVersionFromIdToken(currentToken.idToken); + final incomingVersion = incomingTokenVersion ?? + _extractTokenVersionFromIdToken(incomingIdToken); + final currentVersion = _resolveTokenVersionForModel(currentToken); + final currentUpdatedAtMs = _resolveUpdatedAtForModel(currentToken); if (incomingVersion != null && currentVersion != null && @@ -143,14 +156,26 @@ class WatchSyncHelper { return false; } + if (incomingUpdatedAtMs != null && + currentUpdatedAtMs != null && + incomingUpdatedAtMs != currentUpdatedAtMs) { + return incomingUpdatedAtMs > currentUpdatedAtMs; + } + if (incomingUpdatedAtMs != null && currentUpdatedAtMs == null) { + return true; + } + if (incomingUpdatedAtMs == null && currentUpdatedAtMs != null) { + return false; + } + final currentRefresh = currentToken.refreshToken; if (incomingRefreshToken != null && currentRefresh != null && incomingRefreshToken != currentRefresh) { - if (incomingUpdatedAtMs != null && incomingUpdatedAtMs > 0) { + if (incomingIdToken != null && incomingIdToken != currentToken.idToken) { return true; } - return incomingIdToken != null && incomingIdToken != currentToken.idToken; + return false; } return false; @@ -161,6 +186,10 @@ class WatchSyncHelper { bool includeSentAt = false, }) { final nowMs = DateTime.now().millisecondsSinceEpoch; + final tokenVersion = _resolveTokenVersionForModel(token) ?? nowMs; + final updatedAtMs = (token.updatedAtMs != null && token.updatedAtMs! > 0) + ? token.updatedAtMs! + : nowMs; final payload = { 'studentId': token.studentId, 'studentIdNorm': token.studentIdNorm, @@ -169,8 +198,8 @@ class WatchSyncHelper { 'accessToken': token.accessToken, 'refreshToken': token.refreshToken, 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, - 'tokenVersion': _resolveTokenVersionForSend(token), - 'updatedAtMs': nowMs, + 'tokenVersion': tokenVersion, + 'updatedAtMs': updatedAtMs, }; if (includeSentAt) { payload['sentAtMs'] = nowMs; @@ -185,6 +214,11 @@ class WatchSyncHelper { _watchChannel.setMethodCallHandler(_handleMethodCall); debugPrint('[WatchSync] Handler initialized'); + unawaited(_invokeMethodWithTimeout( + 'watchSyncReady', + null, + const Duration(seconds: 2), + )); } static Future _handleMethodCall(MethodCall call) async { @@ -362,6 +396,8 @@ class WatchSyncHelper { tokenData['accessToken'] as String, tokenData['refreshToken'] as String, watchExpiry, + tokenVersion: watchTokenVersion, + updatedAtMs: watchUpdatedAtMs ?? DateTime.now().millisecondsSinceEpoch, ); await initData.isar.writeTxn(() async { @@ -512,6 +548,9 @@ class WatchSyncHelper { tokenData['accessToken'] as String, tokenData['refreshToken'] as String, iCloudExpiry, + tokenVersion: iCloudTokenVersion, + updatedAtMs: + iCloudUpdatedAtMs ?? DateTime.now().millisecondsSinceEpoch, ); await effectiveIsar.writeTxn(() async { @@ -675,6 +714,9 @@ class WatchSyncHelper { tokenData['accessToken'] as String, tokenData['refreshToken'] as String, watchExpiry, + tokenVersion: watchTokenVersion, + updatedAtMs: + watchUpdatedAtMs ?? DateTime.now().millisecondsSinceEpoch, ); await effectiveIsar.writeTxn(() async { diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 2a40779..d8cbf76 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -207,9 +207,21 @@ Future _initData(AppInitialization init) async { resetOldTimeTableCache(init.isar); resetOldHomeworkCache(init.isar); - if (init.tokens.isNotEmpty) { - final allTokens = await init.isar.tokenModels.where().findAll(); - init.tokens = allTokens; + if (Platform.isIOS) { + try { + await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: init.isar, + tokens: init.tokens, + ); + } catch (e) { + logger.warning('[Init] iCloud recovery check failed: $e'); + } + } + + final allTokens = await init.isar.tokenModels.where().findAll(); + init.tokens = allTokens; + + if (allTokens.isNotEmpty) { final token = pickActiveToken(tokens: allTokens, settings: init.settings); if (token == null) { logger.warning( @@ -221,7 +233,7 @@ Future _initData(AppInitialization init) async { if (Platform.isIOS) { final expiryDate = token.expiryDate; - if (expiryDate != null) { + if (expiryDate != null && expiryDate.isAfter(DateTime.now())) { KretaClient.clearReauthFlag(); }