diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift index 37afeb13..dce2f533 100644 --- a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift +++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift @@ -328,16 +328,30 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { } let token = try decoder.decode(WatchToken.self, from: jsonData) + let currentToken = TokenManager.shared.loadToken() + let shouldForceAccountSwitch: Bool + if incomingSentAtMs > 0, + let currentToken, + !token.isSameAccount(as: currentToken) { + shouldForceAccountSwitch = true + } else { + shouldForceAccountSwitch = false + } + if incomingSentAtMs <= 0, - let currentToken = TokenManager.shared.loadToken(), + let currentToken, !token.isNewer(than: currentToken) { print("[Watch] Ignoring stale token_update without sentAtMs") return } - print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs))") + print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs), forceSwitch: \(shouldForceAccountSwitch))") - try TokenManager.shared.saveToken(token, syncToICloud: false) + try TokenManager.shared.saveToken( + token, + syncToICloud: false, + forceAccountSwitch: shouldForceAccountSwitch + ) print("[Watch] Token saved successfully") if incomingSentAtMs > 0 { lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs) diff --git a/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift index 0108f6e7..794b178f 100644 --- a/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift @@ -258,6 +258,15 @@ struct ReauthRequiredView: View { private func processAuthData(_ authDict: [String: Any]) { do { + func parseInt64(_ value: Any?) -> Int64? { + if let value = value as? Int64 { return value } + if let value = value as? Int { return Int64(value) } + if let value = value as? Double { return Int64(value) } + if let value = value as? String, let parsed = Int64(value) { return parsed } + return nil + } + + let incomingSentAtMs = parseInt64(authDict["sentAtMs"]) ?? 0 let jsonData = try JSONSerialization.data(withJSONObject: authDict) let decoder = JSONDecoder() @@ -268,7 +277,21 @@ struct ReauthRequiredView: View { } let token = try decoder.decode(WatchToken.self, from: jsonData) - try TokenManager.shared.saveToken(token, syncToICloud: false) + let currentToken = TokenManager.shared.loadToken() + let shouldForceAccountSwitch: Bool + if incomingSentAtMs > 0, + let currentToken, + !token.isSameAccount(as: currentToken) { + shouldForceAccountSwitch = true + } else { + shouldForceAccountSwitch = false + } + + try TokenManager.shared.saveToken( + token, + syncToICloud: false, + forceAccountSwitch: shouldForceAccountSwitch + ) DataStore.shared.checkTokenState() DataStore.shared.clearError() diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift index 34c0f435..7101c255 100644 --- a/firka/ios/Shared/API/TokenManager.swift +++ b/firka/ios/Shared/API/TokenManager.swift @@ -99,34 +99,45 @@ class TokenManager { UserDefaults.standard.set(studentIdNorm, forKey: activeStudentIdNormKey) } + private func localTokenFromKeychainAndFile(preferredStudentIdNorm: Int64? = nil) -> WatchToken? { + let keychainToken = loadTokenFromKeychain() + let fileToken = loadTokenFromFile() + + var candidates: [WatchToken] = [] + if let keychainToken { candidates.append(keychainToken) } + if let fileToken { candidates.append(fileToken) } + + if let preferredStudentIdNorm { + let filtered = candidates.filter { $0.studentIdNorm == preferredStudentIdNorm } + if !filtered.isEmpty { + candidates = filtered + } + } + + return candidates.dropFirst().reduce(candidates.first) { best, candidate in + guard let best else { return candidate } + return candidate.isNewer(than: best) ? candidate : best + } + } + private init() { iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in guard let self = self else { return } let preferredStudentIdNorm = self.getActiveStudentIdNorm() + let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60) + let preferredLocalToken = self.localTokenFromKeychainAndFile( + preferredStudentIdNorm: preferredStudentIdNorm + ) + if let preferredStudentIdNorm, - iCloudToken.studentIdNorm != preferredStudentIdNorm { + iCloudToken.studentIdNorm != preferredStudentIdNorm, + preferredLocalToken != nil { 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() - 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 - } - } - let localToken = localCandidates.dropFirst().reduce(localCandidates.first) { best, candidate in - guard let best else { return candidate } - return candidate.isNewer(than: best) ? candidate : best - } + let localToken = preferredLocalToken ?? self.localTokenFromKeychainAndFile() if let localToken = localToken { if iCloudToken.isNewer(than: localToken) { @@ -186,7 +197,7 @@ class TokenManager { return containerURL.appendingPathComponent(tokenFileName) } - // MARK: - Load Token (fresher-wins strategy) + // MARK: - Load Token (active-account first) func loadToken() -> WatchToken? { let iCloudToken = iCloudTokenManager.shared.loadToken() let keychainToken = loadTokenFromKeychain() @@ -203,19 +214,24 @@ class TokenManager { } let preferredStudentIdNorm = getActiveStudentIdNorm() - let preferredCandidates: [(token: WatchToken, source: String)] + let freshest: (token: WatchToken, source: String) if let preferredStudentIdNorm { let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm } - preferredCandidates = filtered.isEmpty ? candidates : filtered - if filtered.isEmpty { + if let preferredFreshest = filtered.dropFirst().reduce(filtered.first) { best, candidate in + guard let best else { return candidate } + return candidate.token.isNewer(than: best.token) ? candidate : best + } { + freshest = preferredFreshest + } else { print("[TokenManager] Active account token not found locally, falling back to freshest available account") + freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in + candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest + } } } else { - preferredCandidates = candidates - } - - let freshest = preferredCandidates.dropFirst().reduce(preferredCandidates[0]) { currentBest, candidate in - candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest + freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in + candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest + } } setActiveStudentIdNorm(freshest.token.studentIdNorm) @@ -224,7 +240,7 @@ class TokenManager { formatter.timeZone = TimeZone.current 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)))") + print("[TokenManager] Using selected token from \(freshest.source) (expiry: \(formatter.string(from: freshest.token.expiryDate)))") if keychainToken == nil || keychainToken!.studentIdNorm != freshest.token.studentIdNorm || @@ -269,10 +285,18 @@ class TokenManager { } // MARK: - Save Token - func saveToken(_ token: WatchToken, syncToICloud: Bool = false) throws { - if let currentToken = loadToken(), !token.isNewer(than: currentToken) { - print("[TokenManager] Ignoring stale or same token save attempt") - return + func saveToken( + _ token: WatchToken, + syncToICloud: Bool = false, + forceAccountSwitch: Bool = false + ) throws { + if let currentToken = loadToken() { + if forceAccountSwitch && !token.isSameAccount(as: currentToken) { + print("[TokenManager] Forcing token save for explicit account switch (\(currentToken.studentIdNorm) -> \(token.studentIdNorm))") + } else if !token.isNewer(than: currentToken) { + print("[TokenManager] Ignoring stale or same token save attempt") + return + } } print("[TokenManager] Saving token locally (Keychain + file)") @@ -515,8 +539,13 @@ class TokenManager { 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 + if localTokenFromKeychainAndFile( + preferredStudentIdNorm: preferredStudentIdNorm + ) != nil { + print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)") + continue + } + print("[TokenManager] Step 3: Active account token missing locally, considering different-account iCloud token") } iCloudHasToken = true if iCloudToken.expiryDate > Date() { diff --git a/firka/ios/Shared/Models/WatchToken.swift b/firka/ios/Shared/Models/WatchToken.swift index 33cf1696..d0b703e4 100644 --- a/firka/ios/Shared/Models/WatchToken.swift +++ b/firka/ios/Shared/Models/WatchToken.swift @@ -66,7 +66,34 @@ struct WatchToken: Codable { func isNewer(than other: WatchToken) -> Bool { if !isSameAccount(as: other) { - return expiryDate > other.expiryDate + let incomingUpdatedAt = effectiveUpdatedAtMs + let currentUpdatedAt = other.effectiveUpdatedAtMs + if let incomingUpdatedAt, let currentUpdatedAt, incomingUpdatedAt != currentUpdatedAt { + return incomingUpdatedAt > currentUpdatedAt + } + if let _ = incomingUpdatedAt, currentUpdatedAt == nil { + return true + } + if incomingUpdatedAt == nil, let _ = currentUpdatedAt { + return false + } + + let incomingVersion = effectiveTokenVersion + let currentVersion = other.effectiveTokenVersion + if let incomingVersion, let currentVersion, incomingVersion != currentVersion { + return incomingVersion > currentVersion + } + if let _ = incomingVersion, currentVersion == nil { + return true + } + if incomingVersion == nil, let _ = currentVersion { + return false + } + + if expiryDate != other.expiryDate { + return expiryDate > other.expiryDate + } + return false } let incomingVersion = effectiveTokenVersion diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index 28867fbb..28929d3f 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -9,6 +9,7 @@ import 'package:firka/helpers/extensions.dart'; import 'package:firka/helpers/live_activity_service.dart'; import 'package:firka/helpers/settings.dart'; import 'package:firka/helpers/update_notifier.dart'; +import 'package:firka/helpers/watch_sync_helper.dart'; import 'package:firka/main.dart'; import 'package:firka/ui/model/style.dart'; import 'package:firka/ui/phone/pages/extras/main_wear_pair.dart'; @@ -87,6 +88,7 @@ class _HomeScreenState extends FirkaState { bool _disposed = false; bool _preloadDone = false; int forcedNav = 0; + bool _didRunSecondaryICloudRecovery = false; final RefreshController _refreshController = RefreshController(initialRefresh: false); @@ -201,12 +203,68 @@ class _HomeScreenState extends FirkaState { } } + Future _runSecondaryICloudRecoveryIfNeeded() async { + if (!Platform.isIOS || _didRunSecondaryICloudRecovery) return; + _didRunSecondaryICloudRecovery = true; + + final activeToken = pickActiveToken( + tokens: widget.data.tokens, + settings: widget.data.settings, + preferredStudentIdNorm: widget.data.client.model.studentIdNorm, + ); + + final now = DateTime.now(); + final shouldRunRecovery = KretaClient.needsReauth || + activeToken == null || + activeToken.expiryDate == null || + activeToken.expiryDate!.isBefore(now.add(const Duration(seconds: 60))); + + if (!shouldRunRecovery) { + return; + } + + logger.info( + '[Home] Secondary iCloud recovery scheduled (5s delay, startup safety pass)'); + await Future.delayed(const Duration(seconds: 5)); + if (_disposed) return; + + try { + final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: widget.data.isar, + tokens: widget.data.tokens, + client: widget.data.client, + ); + if (!recovered) { + logger.info('[Home] Secondary iCloud recovery found no fresher token'); + return; + } + + final refreshedTokens = initDone ? initData.tokens : widget.data.tokens; + widget.data.tokens = refreshedTokens; + + final selectedToken = pickActiveToken( + tokens: refreshedTokens, + settings: widget.data.settings, + preferredStudentIdNorm: widget.data.client.model.studentIdNorm, + ); + if (selectedToken != null) { + widget.data.client.model = selectedToken; + } + KretaClient.clearReauthFlag(); + logger.info('[Home] Secondary iCloud recovery applied a fresher token'); + } catch (e) { + logger.warning('[Home] Secondary iCloud recovery failed: $e'); + } + } + void prefetch() async { if (_prefetched) return; try { _prefetched = true; + await _runSecondaryICloudRecoveryIfNeeded(); + try { await widget.data.client.refreshTokenProactively().timeout( const Duration(seconds: 60),