From 0b78712e64560923fd6555de02c8372679d459f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Wed, 11 Feb 2026 10:37:14 +0100 Subject: [PATCH] Token recovery and sync refactor; LiveActivity UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize and harden token recovery and synchronization across watch/phone components and update LiveActivity UI for iOS 18. Highlights: - Introduced a centralized recovery flow in TokenManager.recoverToken() with locking, multi-step recovery (local refresh, keychain/file/watch, iCloud retries) and refreshTokenInternal helper. Added proactive refresh and improved refresh logic to include tokenVersion/updatedAtMs metadata. - Added tokenVersion and updatedAtMs to WatchToken and propagated these fields through iCloud, WatchConnectivity, WatchSessionManager, and ReauthRequiredView payloads. Many save/load paths now avoid unnecessary iCloud sync and ignore stale tokens based on version/timestamps. - WatchConnectivityManager now filters stale incoming updates using sentAtMs and persists lastAppliedTokenUpdateMs to avoid regressions; payloads include updatedAtMs/tokenVersion when available. - DataStore and KretaAPIClient use the centralized recovery API instead of ad-hoc retry logic or direct WatchConnectivity requests. - iCloudTokenManager stores tokenVersion/updatedAtMs, ignores stale saves, and uses consistent timestamps. - LiveActivityWidget and LiveActivity updated: iOS 18+ uses new adaptive/family-aware views (including a new SmallActivityView) while legacy behavior is preserved for iOS 16.2–17.x. These changes aim to make token synchronization more reliable across devices and OS versions, reduce duplicate/conflicting writes, and provide a more adaptive live activity UI on newer iOS releases. --- .../Services/DataStore.swift | 133 +------ .../Services/WatchConnectivityManager.swift | 67 +++- .../Views/ReauthRequiredView.swift | 8 +- .../ActivityWidgetBundle.swift | 9 +- .../ios/LiveActivityWidget/LiveActivity.swift | 359 +++++++++++++++++- firka/ios/Runner/WatchSessionManager.swift | 34 +- firka/ios/Shared/API/KretaAPIClient.swift | 95 +---- firka/ios/Shared/API/TokenManager.swift | 334 ++++++++++++++-- firka/ios/Shared/API/iCloudTokenManager.swift | 33 +- firka/ios/Shared/Models/WatchToken.swift | 118 ++++++ .../lib/helpers/api/client/kreta_client.dart | 338 +++++++++-------- firka/lib/helpers/api/token_grant.dart | 61 +-- firka/lib/helpers/watch_sync_helper.dart | 326 +++++++++++----- firka/lib/main.dart | 80 +--- firka/lib/ui/phone/pages/home/home_main.dart | 6 + .../ui/phone/pages/home/home_timetable.dart | 23 +- .../ui/phone/screens/home/home_screen.dart | 84 ++-- firka/lib/ui/phone/widgets/login_webview.dart | 16 +- 18 files changed, 1449 insertions(+), 675 deletions(-) diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index 22e58c0..2523468 100644 --- a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -1,7 +1,6 @@ import Foundation import Observation import WidgetKit -import WatchConnectivity // MARK: - Cache Wrapper @@ -156,138 +155,30 @@ class DataStore { isRecoveringToken = true recoveryAttempted = false error = nil - print("[Watch] Starting background token recovery...") + print("[Watch] Starting token recovery via central method...") defer { isRecoveringToken = false } - let retryDelays: [UInt64] = [0, 2, 5] - var isNetworkError = false - var isTokenPermanentlyInvalid = false - - for (attemptIndex, delaySeconds) in retryDelays.enumerated() { - if delaySeconds > 0 { - print("[Watch] Recovery attempt \(attemptIndex + 1): waiting \(delaySeconds)s before retry...") - try? await Task.sleep(nanoseconds: delaySeconds * 1_000_000_000) - } - - print("[Watch] Recovery attempt \(attemptIndex + 1)/\(retryDelays.count) - Step 1: Checking iCloud for updated token...") - if let iCloudToken = iCloudTokenManager.shared.loadToken() { - if !isTokenExpired(iCloudToken) { - print("[Watch] Recovery: Found valid token in iCloud!") - try? TokenManager.shared.saveToken(iCloudToken) - checkTokenState() - return true - } else { - print("[Watch] Recovery: iCloud token is expired, trying refresh...") - } - } - - print("[Watch] Recovery attempt \(attemptIndex + 1)/\(retryDelays.count) - Step 2: Attempting API token refresh...") - do { - _ = try await TokenManager.shared.refreshToken() - print("[Watch] Recovery: Token refresh succeeded!") - checkTokenState() - return true - } catch let tokenError as TokenError { - print("[Watch] Recovery: API token refresh failed: \(tokenError)") - switch tokenError { - case .networkError: - isNetworkError = true - case .refreshExpired, .invalidGrant: - isTokenPermanentlyInvalid = true - default: - break - } - } catch { - print("[Watch] Recovery: API token refresh failed with unknown error: \(error)") - isNetworkError = true - } - - print("[Watch] Recovery attempt \(attemptIndex + 1)/\(retryDelays.count) - Step 3: Checking if iPhone is reachable...") - if await requestTokenFromiPhoneAsync() { - print("[Watch] Recovery: Got token from iPhone!") - checkTokenState() - return true - } - - if attemptIndex < retryDelays.count - 1 { - print("[Watch] Recovery attempt \(attemptIndex + 1) failed, retrying...") - } + if let token = TokenManager.shared.loadToken(), !TokenManager.shared.isTokenExpired() { + print("[Watch] Recovery: Token is already valid") + checkTokenState() + return true } - if isTokenPermanentlyInvalid { - print("[Watch] Recovery: Token permanently invalid, showing reauth screen") - recoveryAttempted = true - self.error = "token_expired" - } else if isNetworkError { - print("[Watch] Recovery: Network error - not showing reauth, user can retry") - self.error = "network" - } else { - print("[Watch] Recovery: All retries failed, treating as transient/network issue") - self.error = "network" + if let _ = await TokenManager.shared.recoverToken() { + print("[Watch] Recovery: Central recovery succeeded") + checkTokenState() + return true } + print("[Watch] Recovery: All attempts failed") + recoveryAttempted = true + self.error = "token_expired" return false } - private func isTokenExpired(_ token: WatchToken) -> Bool { - let expiryThreshold = token.expiryDate.addingTimeInterval(-60) - return Date() >= expiryThreshold - } - - private func requestTokenFromiPhoneAsync() async -> Bool { - return await withCheckedContinuation { continuation in - guard WCSession.default.activationState == .activated, - WCSession.default.isReachable else { - print("[Watch] Recovery: iPhone not reachable") - continuation.resume(returning: false) - return - } - - print("[Watch] Recovery: Requesting token from iPhone...") - - WCSession.default.sendMessage( - ["action": "requestToken"], - replyHandler: { response in - if let authDict = response["auth"] as? [String: Any] { - print("[Watch] Recovery: Received token from iPhone") - self.processAuthDataSync(authDict) - continuation.resume(returning: true) - } else { - print("[Watch] Recovery: iPhone returned no token") - continuation.resume(returning: false) - } - }, - errorHandler: { error in - print("[Watch] Recovery: iPhone request failed: \(error.localizedDescription)") - continuation.resume(returning: false) - } - ) - - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - } - } - } - - private func processAuthDataSync(_ authDict: [String: Any]) { - do { - let jsonData = try JSONSerialization.data(withJSONObject: authDict) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let timestamp = try container.decode(Int64.self) - return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) - } - let token = try decoder.decode(WatchToken.self, from: jsonData) - try TokenManager.shared.saveToken(token) - print("[Watch] Recovery: Token saved from iPhone") - } catch { - print("[Watch] Recovery: Failed to process auth data: \(error)") - } - } - private func refreshComplications() { WidgetCenter.shared.reloadAllTimelines() print("[Watch] Complications refreshed") diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift index 2efccef..37afeb1 100644 --- a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift +++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift @@ -3,11 +3,38 @@ import WatchConnectivity class WatchConnectivityManager: NSObject, WCSessionDelegate { static let shared = WatchConnectivityManager() + private let lastAppliedTokenUpdateKey = "watch_last_applied_token_update_ms" private override init() { super.init() } + private var lastAppliedTokenUpdateMs: Int64 { + get { + Int64(UserDefaults.standard.double(forKey: lastAppliedTokenUpdateKey)) + } + set { + UserDefaults.standard.set(Double(newValue), forKey: lastAppliedTokenUpdateKey) + } + } + + private func extractSentAtMs(from authDict: [String: Any]) -> Int64? { + if let value = authDict["sentAtMs"] as? Int64 { + return value + } + if let value = authDict["sentAtMs"] as? Int { + return Int64(value) + } + if let value = authDict["sentAtMs"] as? Double { + return Int64(value) + } + if let value = authDict["sentAtMs"] as? String, + let parsed = Int64(value) { + return parsed + } + return nil + } + func activate() { print("[Watch] WatchConnectivityManager.activate() called") if WCSession.isSupported() { @@ -92,7 +119,7 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { let freshToken = try await KretaAPIClient.shared.getValidToken() print("[Watch] Token refresh succeeded, sending fresh token to iPhone") - let tokenData: [String: Any] = [ + var tokenData: [String: Any] = [ "studentId": freshToken.studentId, "studentIdNorm": freshToken.studentIdNorm, "iss": freshToken.iss, @@ -101,6 +128,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { "refreshToken": freshToken.refreshToken, "expiryDate": Int64(freshToken.expiryDate.timeIntervalSince1970 * 1000) ] + if let tokenVersion = freshToken.effectiveTokenVersion { + tokenData["tokenVersion"] = tokenVersion + } + tokenData["updatedAtMs"] = freshToken.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000) replyHandler(["token": tokenData]) } catch { @@ -116,7 +147,7 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { return } - let tokenData: [String: Any] = [ + var tokenData: [String: Any] = [ "studentId": token.studentId, "studentIdNorm": token.studentIdNorm, "iss": token.iss, @@ -125,6 +156,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { "refreshToken": token.refreshToken, "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) ] + if let tokenVersion = token.effectiveTokenVersion { + tokenData["tokenVersion"] = tokenVersion + } + tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000) let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" @@ -210,7 +245,7 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { return } - let tokenData: [String: Any] = [ + var tokenData: [String: Any] = [ "studentId": token.studentId, "studentIdNorm": token.studentIdNorm, "iss": token.iss, @@ -219,6 +254,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { "refreshToken": token.refreshToken, "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) ] + if let tokenVersion = token.effectiveTokenVersion { + tokenData["tokenVersion"] = tokenVersion + } + tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000) do { try WCSession.default.updateApplicationContext(["auth": tokenData]) @@ -271,6 +310,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { private func processAuthData(_ authDict: [String: Any]) { print("[Watch] processAuthData called") do { + let incomingSentAtMs = extractSentAtMs(from: authDict) ?? 0 + let previousSentAtMs = lastAppliedTokenUpdateMs + + if incomingSentAtMs > 0 && incomingSentAtMs < previousSentAtMs { + print("[Watch] Ignoring stale token_update (sentAtMs: \(incomingSentAtMs), lastApplied: \(previousSentAtMs))") + return + } + let jsonData = try JSONSerialization.data(withJSONObject: authDict) let decoder = JSONDecoder() @@ -281,10 +328,20 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { } let token = try decoder.decode(WatchToken.self, from: jsonData) - print("[Watch] Token decoded, saving...") + if incomingSentAtMs <= 0, + let currentToken = TokenManager.shared.loadToken(), + !token.isNewer(than: currentToken) { + print("[Watch] Ignoring stale token_update without sentAtMs") + return + } - try TokenManager.shared.saveToken(token) + print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs))") + + try TokenManager.shared.saveToken(token, syncToICloud: false) print("[Watch] Token saved successfully") + if incomingSentAtMs > 0 { + lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs) + } DataStore.shared.checkTokenState() diff --git a/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift index 4a88f1c..0108f6e 100644 --- a/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift @@ -212,7 +212,7 @@ struct ReauthRequiredView: View { print("[Watch] Sending Watch token to iPhone...") - let tokenData: [String: Any] = [ + var tokenData: [String: Any] = [ "studentId": token.studentId, "studentIdNorm": token.studentIdNorm, "iss": token.iss, @@ -221,6 +221,10 @@ struct ReauthRequiredView: View { "refreshToken": token.refreshToken, "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) ] + if let tokenVersion = token.effectiveTokenVersion { + tokenData["tokenVersion"] = tokenVersion + } + tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000) WCSession.default.sendMessage( ["action": "receiveTokenFromWatch", "token": tokenData], @@ -264,7 +268,7 @@ struct ReauthRequiredView: View { } let token = try decoder.decode(WatchToken.self, from: jsonData) - try TokenManager.shared.saveToken(token) + try TokenManager.shared.saveToken(token, syncToICloud: false) DataStore.shared.checkTokenState() DataStore.shared.clearError() diff --git a/firka/ios/LiveActivityWidget/ActivityWidgetBundle.swift b/firka/ios/LiveActivityWidget/ActivityWidgetBundle.swift index fb04aeb..3bd64a5 100644 --- a/firka/ios/LiveActivityWidget/ActivityWidgetBundle.swift +++ b/firka/ios/LiveActivityWidget/ActivityWidgetBundle.swift @@ -4,8 +4,15 @@ import SwiftUI @main struct TimetableWidgetBundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.2, *) { + if #available(iOS 18.0, *) { TimetableLiveActivity() } + + if #available(iOS 16.2, *), + !ProcessInfo.processInfo.isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0) + ) { + TimetableLiveActivityLegacy() + } } } diff --git a/firka/ios/LiveActivityWidget/LiveActivity.swift b/firka/ios/LiveActivityWidget/LiveActivity.swift index de85997..8400373 100644 --- a/firka/ios/LiveActivityWidget/LiveActivity.swift +++ b/firka/ios/LiveActivityWidget/LiveActivity.swift @@ -2,12 +2,24 @@ import ActivityKit import WidgetKit import SwiftUI -@available(iOS 16.2, *) +@available(iOS 18.0, *) struct TimetableLiveActivity: Widget { var body: some WidgetConfiguration { + TimetableLiveActivityLegacy.activityConfiguration + .supplementalActivityFamilies([.small]) + } +} + +@available(iOS 16.2, *) +struct TimetableLiveActivityLegacy: Widget { + var body: some WidgetConfiguration { + Self.activityConfiguration + } + + static var activityConfiguration: ActivityConfiguration { ActivityConfiguration(for: TimetableActivityAttributes.self) { context in // Lock screen/banner UI - TimetableLiveActivityView(context: context) + TimetableLiveActivityAdaptiveView(context: context) .activityBackgroundTint(Color(red: 0.1, green: 0.15, blue: 0.1)) .activitySystemActionForegroundColor(Color.white) } dynamicIsland: { context in @@ -282,6 +294,36 @@ struct TimetableLiveActivity: Widget { } } +@available(iOS 16.2, *) +struct TimetableLiveActivityAdaptiveView: View { + let context: ActivityViewContext + + var body: some View { + if #available(iOS 18.0, *) { + TimetableLiveActivityFamilyView(context: context) + } else { + TimetableLiveActivityView(context: context) + } + } +} + +@available(iOS 18.0, *) +struct TimetableLiveActivityFamilyView: View { + @Environment(\.activityFamily) private var activityFamily + let context: ActivityViewContext + + var body: some View { + switch activityFamily { + case .small: + SmallActivityView(context: context) + case .medium: + TimetableLiveActivityView(context: context) + @unknown default: + TimetableLiveActivityView(context: context) + } + } +} + // MARK: - Lock Screen View @available(iOS 16.2, *) struct TimetableLiveActivityView: View { @@ -564,3 +606,316 @@ struct TimetableLiveActivityView: View { .padding(16) } } + +@available(iOS 18.0, *) +struct SmallActivityView: View { + let context: ActivityViewContext + + private var mode: String { + context.state.mode ?? (context.state.isBreak ? "break" : "lesson") + } + + private var season: String { + context.state.season ?? "" + } + + private var titleText: String { + switch mode { + case "beforeSchool": + return firstNonEmpty([ + trimmedOrNil(context.state.labels?.title), + trimmedOrNil(context.state.lessonName), + trimmedOrNil(context.state.message), + ]) ?? "" + case "xmas", "newYearEve", "newYearDay": + return firstNonEmpty([ + trimmedOrNil(context.state.message), + trimmedOrNil(context.state.lessonName), + trimmedOrNil(context.state.labels?.title), + ]) ?? "" + case "seasonalBreak": + return firstNonEmpty([ + trimmedOrNil(context.state.labels?.title), + trimmedOrNil(context.state.lessonName), + trimmedOrNil(context.state.message), + ]) ?? "" + case "break": + return firstNonEmpty([ + trimmedOrNil(context.state.labels?.title), + trimmedOrNil(context.state.message), + ]) ?? "" + default: + return firstNonEmpty([ + trimmedOrNil(context.state.lessonName), + trimmedOrNil(context.state.labels?.title), + trimmedOrNil(context.state.message), + ]) ?? "" + } + } + + private var iconName: String { + if SeasonalIconHelper.isSeasonalMode(mode) || mode == "beforeSchool" { + return SeasonalIconHelper.iconName(for: mode, season: season, lessonIcon: context.state.lessonIcon) + } + + return context.state.isBreak ? "cup.and.saucer.fill" : (context.state.lessonIcon ?? "book.fill") + } + + private var warningState: (text: String, color: Color, icon: String)? { + let showWarningModes = ["newYearEve", "lesson", "break", "seasonalBreak"] + guard showWarningModes.contains(mode), + let warningText = trimmedOrNil(context.state.tokenExpirationWarning) else { + return nil + } + + let isExpired = context.state.tokenExpired ?? false + return ( + text: warningText, + color: isExpired ? .red : .orange, + icon: isExpired ? "exclamationmark.circle.fill" : "exclamationmark.triangle.fill" + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + Image(systemName: iconName) + .font(.system(size: 14)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season)) + + if mode == "lesson", let lessonNumber = context.state.lessonNumber { + Text("\(lessonNumber). \(titleText)") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.65) + .truncationMode(.tail) + } else { + Text(titleText) + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.65) + .truncationMode(.tail) + } + + Spacer(minLength: 0) + } + + detailsSection + + Spacer(minLength: 0) + + timerSection + + if let warningState = warningState { + HStack(spacing: 3) { + Image(systemName: warningState.icon) + .font(.system(size: 8)) + .foregroundColor(warningState.color) + + Text(warningState.text) + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(warningState.color) + .lineLimit(1) + .minimumScaleFactor(0.55) + .truncationMode(.tail) + } + .padding(.top, 1) + } + } + .padding(10) + } + + @ViewBuilder + private var detailsSection: some View { + switch mode { + case "beforeSchool": + labeledDetailLine( + label: context.state.labels?.firstLessonLabel, + value: context.state.lessonName, + color: .white, + weight: .semibold + ) + labeledDetailLine( + label: context.state.labels?.teacherLabel, + value: context.state.teacherName, + color: .white, + weight: .semibold + ) + labeledDetailLine( + label: context.state.labels?.roomLabel, + value: context.state.roomName, + color: .white, + weight: .semibold + ) + + case "lesson": + if context.state.isSubstitution ?? false { + if let substitution = trimmedOrNil(context.state.labels?.substitutionText) { + if let substituteTeacher = trimmedOrNil(context.state.substituteTeacher) { + detailLine("\(substitution) (\(substituteTeacher))", color: .orange) + } else { + detailLine(substitution, color: .orange) + } + } + } + + case "break": + labeledDetailLine( + label: context.state.labels?.nextLabel, + value: context.state.nextLessonName, + color: .white, + weight: .semibold + ) + labeledDetailLine( + label: context.state.labels?.roomLabel, + value: context.state.nextRoomName, + color: .white, + weight: .semibold + ) + + case "seasonalBreak": + if let remainingLabel = trimmedOrNil(context.state.labels?.remainingLabel) { + detailLine(remainingLabel) + } + + default: + EmptyView() + } + } + + @ViewBuilder + private var timerSection: some View { + switch mode { + case "xmas", "newYearDay": + EmptyView() + + case "seasonalBreak": + if let remainingLabel = trimmedOrNil(context.state.labels?.remainingLabel) { + timerLabelLine(remainingLabel) + } + timerValueText(context.state.seasonalDisplayValue) + + case "newYearEve", "beforeSchool": + if let timerLabel = trimmedOrNil(context.state.labels?.timerLabel) { + if mode == "beforeSchool" { + timerLabelLine(timerLabel, color: .white, weight: .semibold) + } else { + timerLabelLine(timerLabel) + } + } + countdownValueView() + + case "lesson", "break", "loading": + if context.state.isCancelled ?? false { + if let cancelled = trimmedOrNil(context.state.labels?.cancelledText) { + timerValueText(cancelled, color: .red) + } + } else { + if let timerLabel = trimmedOrNil(context.state.labels?.timerLabel) { + if mode == "lesson" || mode == "break" { + timerLabelLine(timerLabel, color: .white, weight: .semibold) + } else { + timerLabelLine(timerLabel) + } + } + countdownValueView() + } + + default: + countdownValueView() + } + } + + @ViewBuilder + private func labeledDetailLine( + label: String?, + value: String?, + color: Color = .gray, + weight: Font.Weight = .regular + ) -> some View { + if let text = joinedLabelValue(label: label, value: value) { + detailLine(text, color: color, weight: weight) + } + } + + @ViewBuilder + private func countdownValueView() -> some View { + if context.state.endTime > context.state.currentTime { + Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + timerValueText(context.state.formattedEndTime) + } + } + + private func detailLine( + _ text: String, + color: Color = .gray, + weight: Font.Weight = .regular + ) -> some View { + Text(text) + .font(.system(size: 9, weight: weight)) + .foregroundColor(color) + .lineLimit(1) + .minimumScaleFactor(0.6) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func timerLabelLine( + _ text: String, + color: Color = .gray, + weight: Font.Weight = .regular + ) -> some View { + Text(text) + .font(.system(size: 8, weight: weight)) + .foregroundColor(color) + .lineLimit(1) + .minimumScaleFactor(0.65) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func timerValueText(_ text: String, color: Color? = nil) -> some View { + Text(text) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(color ?? SeasonalIconHelper.iconColor(for: mode, season: season)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.75) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func joinedLabelValue(label: String?, value: String?) -> String? { + guard let value = trimmedOrNil(value) else { return nil } + + if let label = trimmedOrNil(label) { + return "\(label) \(value)" + } + + return value + } + + private func firstNonEmpty(_ values: [String?]) -> String? { + for value in values { + if let value = value, !value.isEmpty { + return value + } + } + return nil + } + + private func trimmedOrNil(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift index af165b6..7703b8b 100644 --- a/firka/ios/Runner/WatchSessionManager.swift +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -59,6 +59,22 @@ class WatchSessionManager: NSObject, WCSessionDelegate { flutterChannel?.invokeMethod("onTokenRecoveredFromiCloud", arguments: nil) } + private 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 + } + 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)) @@ -180,7 +196,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { formatter.dateFormat = "HH:mm:ss" print("[WatchSessionManager] Found iCloud token, expiry: \(formatter.string(from: token.expiryDate))") - let tokenData: [String: Any] = [ + var tokenData: [String: Any] = [ "studentId": token.studentId, "studentIdNorm": token.studentIdNorm, "iss": token.iss, @@ -189,6 +205,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate { "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) } @@ -204,13 +226,15 @@ class WatchSessionManager: NSObject, WCSessionDelegate { let idToken = tokenData["idToken"] as? String, let iss = tokenData["iss"] as? String, let studentId = tokenData["studentId"] as? String, - let expiryMs = tokenData["expiryDate"] as? Int64 else { + let expiryMs = parseInt64(tokenData["expiryDate"]) else { result(FlutterError(code: "INVALID_ARGS", message: "Missing required token fields", details: nil)) return } - let studentIdNorm = tokenData["studentIdNorm"] as? Int64 ?? 0 + let studentIdNorm = parseInt64(tokenData["studentIdNorm"]) ?? 0 let expiryDate = Date(timeIntervalSince1970: Double(expiryMs) / 1000.0) + let tokenVersion = parseInt64(tokenData["tokenVersion"]) ?? WatchToken.extractIatMillis(from: idToken) + let updatedAtMs = parseInt64(tokenData["updatedAtMs"]) ?? Int64(Date().timeIntervalSince1970 * 1000) let token = WatchToken( accessToken: accessToken, @@ -219,7 +243,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate { iss: iss, studentId: studentId, studentIdNorm: studentIdNorm, - expiryDate: expiryDate + expiryDate: expiryDate, + tokenVersion: tokenVersion, + updatedAtMs: updatedAtMs ) iCloudTokenManager.shared.saveToken(token, deviceName: "iPhone") diff --git a/firka/ios/Shared/API/KretaAPIClient.swift b/firka/ios/Shared/API/KretaAPIClient.swift index 387fd56..08d92cc 100644 --- a/firka/ios/Shared/API/KretaAPIClient.swift +++ b/firka/ios/Shared/API/KretaAPIClient.swift @@ -1,7 +1,4 @@ import Foundation -#if os(watchOS) -import WatchConnectivity -#endif // MARK: - API Error Types @@ -89,98 +86,24 @@ class KretaAPIClient { // MARK: - Token Management - private let retryDelays: [Double] = [1, 10, 30, 60] - func getValidToken() async throws -> WatchToken { - if !TokenManager.shared.isTokenExpired() { - guard let token = TokenManager.shared.loadToken() else { - throw APIError.tokenError(.noToken) - } - return token - } - - #if os(watchOS) - if await requestTokenFromiPhoneIfReachable() { - if let token = TokenManager.shared.loadToken(), !TokenManager.shared.isTokenExpired() { - print("[KretaAPI] Using token received from iPhone") + if let token = TokenManager.shared.loadToken() { + let expiryThreshold = token.expiryDate.addingTimeInterval(-60) + if Date() < expiryThreshold { return token } } - #endif - var lastError: TokenError = .noToken - - for (attempt, delay) in retryDelays.enumerated() { - do { - print("[KretaAPI] Token refresh attempt \(attempt + 1)/\(retryDelays.count)") - let token = try await TokenManager.shared.refreshToken() - print("[KretaAPI] Token refresh succeeded on attempt \(attempt + 1)") - return token - } catch let error as TokenError { - lastError = error - print("[KretaAPI] Token refresh failed (attempt \(attempt + 1)): \(error)") - - if error == .refreshExpired || error == .invalidGrant { - print("[KretaAPI] Permanent token error, not retrying") - throw APIError.tokenError(error) - } - - if attempt < retryDelays.count - 1 { - print("[KretaAPI] Waiting \(delay)s before next attempt...") - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - } + print("[KretaAPI] Token invalid or expired, starting recovery...") + if let recoveredToken = await TokenManager.shared.recoverToken() { + print("[KretaAPI] Token recovery succeeded") + return recoveredToken } - print("[KretaAPI] All \(retryDelays.count) token refresh attempts failed") - throw APIError.tokenError(lastError) + print("[KretaAPI] Token recovery failed") + throw APIError.tokenError(.noToken) } - #if os(watchOS) - private func requestTokenFromiPhoneIfReachable() async -> Bool { - guard WCSession.default.activationState == .activated, - WCSession.default.isReachable else { - print("[KretaAPI] iPhone not reachable, will refresh locally") - return false - } - - print("[KretaAPI] Requesting fresh token from iPhone...") - - return await withCheckedContinuation { continuation in - WCSession.default.sendMessage( - ["action": "requestToken"], - replyHandler: { response in - if let authDict = response["auth"] as? [String: Any] { - do { - let jsonData = try JSONSerialization.data(withJSONObject: authDict) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let timestamp = try container.decode(Int64.self) - return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) - } - let token = try decoder.decode(WatchToken.self, from: jsonData) - try TokenManager.shared.saveToken(token) - print("[KretaAPI] Token received from iPhone and saved") - continuation.resume(returning: true) - } catch { - print("[KretaAPI] Failed to process token from iPhone: \(error)") - continuation.resume(returning: false) - } - } else { - print("[KretaAPI] iPhone didn't return a token") - continuation.resume(returning: false) - } - }, - errorHandler: { error in - print("[KretaAPI] Failed to request token from iPhone: \(error)") - continuation.resume(returning: false) - } - ) - } - } - #endif - // MARK: - Private Helper Methods private func performRequest( path: String, diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift index 8197565..63dce64 100644 --- a/firka/ios/Shared/API/TokenManager.swift +++ b/firka/ios/Shared/API/TokenManager.swift @@ -1,5 +1,8 @@ import Foundation import Security +#if os(watchOS) +import WatchConnectivity +#endif // MARK: - Token Response Structure private struct TokenRefreshResponse: Decodable { @@ -43,6 +46,30 @@ class TokenManager { #elseif os(watchOS) private let deviceName = "Watch" #endif + private let recoveryLock = NSLock() + private var recoveryInProgress = false + + private func startRecoveryIfNeeded() -> Bool { + recoveryLock.lock() + defer { recoveryLock.unlock() } + if recoveryInProgress { + return false + } + recoveryInProgress = true + return true + } + + private func finishRecovery() { + recoveryLock.lock() + recoveryInProgress = false + recoveryLock.unlock() + } + + private func isRecoveryRunning() -> Bool { + recoveryLock.lock() + defer { recoveryLock.unlock() } + return recoveryInProgress + } private init() { iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in @@ -54,14 +81,14 @@ class TokenManager { let fileToken = self.loadTokenFromFile() let localToken: WatchToken? = { if let k = keychainToken, let f = fileToken { - return k.expiryDate > f.expiryDate ? k : f + return k.isNewer(than: f) ? k : f } return keychainToken ?? fileToken }() if let localToken = localToken { - if iCloudToken.expiryDate > localToken.expiryDate { - print("[TokenManager] iCloud token is fresher (\(iCloudToken.expiryDate) > \(localToken.expiryDate)), updating local cache") + if iCloudToken.isNewer(than: localToken) { + print("[TokenManager] iCloud token is fresher, updating local cache") try? self.saveTokenToKeychain(iCloudToken) try? self.saveTokenToFile(iCloudToken) @@ -75,8 +102,7 @@ class TokenManager { } #endif } else { - print("[TokenManager] Local token is fresher or equal (local: \(localToken.expiryDate), iCloud: \(iCloudToken.expiryDate)), ignoring iCloud update and pushing local to iCloud") - iCloudTokenManager.shared.saveToken(localToken, deviceName: self.deviceName) + print("[TokenManager] Local token is fresher or equal, ignoring iCloud update") } } else { print("[TokenManager] No local token, using iCloud token") @@ -132,7 +158,9 @@ class TokenManager { return nil } - let freshest = candidates.max(by: { $0.token.expiryDate < $1.token.expiryDate })! + let freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in + candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest + } let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" @@ -141,15 +169,11 @@ class TokenManager { print("[TokenManager] Token sources found: \(candidates.map { "\($0.source): \(formatter.string(from: $0.token.expiryDate))" }.joined(separator: ", "))") print("[TokenManager] Using freshest token from \(freshest.source) (expiry: \(formatter.string(from: freshest.token.expiryDate)))") - if iCloudToken == nil || iCloudToken!.expiryDate < freshest.token.expiryDate { - print("[TokenManager] Syncing fresher token to iCloud") - iCloudTokenManager.shared.saveToken(freshest.token, deviceName: deviceName) - } - if keychainToken == nil || keychainToken!.expiryDate < freshest.token.expiryDate { + if keychainToken == nil || freshest.token.isNewer(than: keychainToken!) { print("[TokenManager] Syncing fresher token to keychain") try? saveTokenToKeychain(freshest.token) } - if fileToken == nil || fileToken!.expiryDate < freshest.token.expiryDate { + if fileToken == nil || freshest.token.isNewer(than: fileToken!) { print("[TokenManager] Syncing fresher token to file") try? saveTokenToFile(freshest.token) } @@ -182,13 +206,20 @@ class TokenManager { try? FileManager.default.removeItem(at: filePath) } - // MARK: - Save Token (to all storage locations) - func saveToken(_ token: WatchToken) throws { - print("[TokenManager] Saving token to all storage locations") + // 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 + } + + print("[TokenManager] Saving token locally (Keychain + file)") try saveTokenToKeychain(token) - iCloudTokenManager.shared.saveToken(token, deviceName: deviceName) + if syncToICloud { + iCloudTokenManager.shared.saveToken(token, deviceName: deviceName) + } guard let filePath = getTokenFilePath() else { throw TokenError.networkError @@ -213,7 +244,9 @@ class TokenManager { // MARK: - Keychain Methods func saveTokenToKeychain(_ token: WatchToken) throws { - let data = try JSONEncoder().encode(token) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(token) let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -256,7 +289,16 @@ class TokenManager { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode(WatchToken.self, from: data) + if let token = try? decoder.decode(WatchToken.self, from: data) { + return token + } + + if let legacyToken = try? JSONDecoder().decode(WatchToken.self, from: data) { + try? saveTokenToKeychain(legacyToken) + return legacyToken + } + + return nil } func deleteTokenFromKeychain() { @@ -290,20 +332,264 @@ class TokenManager { } func refreshTokenProactively() async { - guard shouldRefreshProactively() else { + guard let token = loadToken() 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") return } print("[TokenManager] Proactively refreshing token...") do { - _ = try await refreshToken() + _ = try await refreshTokenInternal(token) print("[TokenManager] Proactive token refresh succeeded") } catch { print("[TokenManager] Proactive token refresh failed: \(error)") } } + // MARK: - Central Token Recovery + func recoverToken() async -> WatchToken? { + if !startRecoveryIfNeeded() { + print("[TokenManager] Recovery already in progress, waiting for current recovery...") + for _ in 0..<40 { + if let token = loadToken(), !isTokenExpired() { + print("[TokenManager] Current recovery produced a valid token") + return token + } + + if !isRecoveryRunning() { + break + } + try? await Task.sleep(nanoseconds: 250_000_000) + } + + if let token = loadToken(), !isTokenExpired() { + print("[TokenManager] Valid token became available after waiting") + return token + } + + print("[TokenManager] Existing recovery did not yield a valid token") + return nil + } + + defer { finishRecovery() } + + print("[TokenManager] Starting central token recovery...") + + print("[TokenManager] Step 1: Trying local token refresh...") + if let token = loadToken() { + do { + let refreshedToken = try await refreshTokenInternal(token) + print("[TokenManager] Step 1 SUCCESS: Local refresh succeeded") + return refreshedToken + } catch { + print("[TokenManager] Step 1 FAILED: Local refresh failed: \(error)") + } + } else { + print("[TokenManager] Step 1 SKIPPED: No local token found") + } + + 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)") + } + } else { + print("[TokenManager] Step 2 SKIPPED: No token from Keychain/Watch") + } + + print("[TokenManager] Step 3: Trying iCloud recovery with retries...") + let retryDelays: [TimeInterval] = [0, 5, 10, 5, 10] + var iCloudHasToken = false + + for (attempt, delay) in retryDelays.enumerated() { + if delay > 0 { + if !iCloudHasToken && attempt > 0 { + print("[TokenManager] Step 3: Skipping retries - iCloud has no token") + break + } + print("[TokenManager] Step 3: Waiting \(Int(delay))s before attempt \(attempt + 1)...") + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + + print("[TokenManager] Step 3: iCloud attempt \(attempt + 1)/\(retryDelays.count)...") + + if let iCloudToken = iCloudTokenManager.shared.loadToken() { + 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)") + } + } else { + print("[TokenManager] Step 3: iCloud token is expired, trying refresh anyway...") + do { + let refreshedToken = try await refreshTokenInternal(iCloudToken) + print("[TokenManager] Step 3 SUCCESS: Expired iCloud token refresh succeeded on attempt \(attempt + 1)") + return refreshedToken + } catch { + print("[TokenManager] Step 3: Expired iCloud token refresh failed on attempt \(attempt + 1): \(error)") + } + } + } else { + print("[TokenManager] Step 3: No token in iCloud on attempt \(attempt + 1)") + if attempt == 0 { + iCloudHasToken = false + } + } + } + + print("[TokenManager] All recovery attempts failed") + return nil + } + + private func tryRecoverFromKeychainAndWatch() async -> WatchToken? { + var candidates: [(token: WatchToken, source: String)] = [] + + if let keychainToken = loadTokenFromKeychain() { + candidates.append((keychainToken, "keychain")) + print("[TokenManager] Found token in Keychain") + } + + if let fileToken = loadTokenFromFile() { + candidates.append((fileToken, "file")) + print("[TokenManager] Found token in file storage") + } + + #if os(watchOS) + if let watchToken = await requestTokenFromiPhoneForRecovery() { + candidates.append((watchToken, "WatchConnectivity")) + print("[TokenManager] Found token from iPhone via WatchConnectivity") + } + #endif + + guard !candidates.isEmpty else { + return nil + } + + let freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in + candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest + } + print("[TokenManager] Using freshest token from \(freshest.source)") + return freshest.token + } + + #if os(watchOS) + private func requestTokenFromiPhoneForRecovery() async -> WatchToken? { + guard WCSession.default.activationState == .activated, + WCSession.default.isReachable else { + print("[TokenManager] iPhone not reachable for recovery") + return nil + } + + let timeoutSeconds: UInt64 = 10 + + return await withTaskGroup(of: WatchToken?.self) { group in + group.addTask { + do { + try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) + } catch { + return nil + } + if Task.isCancelled { + return nil + } + print("[TokenManager] iPhone request timed out after \(timeoutSeconds)s") + return nil + } + + group.addTask { + await withCheckedContinuation { continuation in + var hasResumed = false + let resumeOnce: (WatchToken?) -> Void = { token in + guard !hasResumed else { return } + hasResumed = true + continuation.resume(returning: token) + } + + WCSession.default.sendMessage( + ["action": "requestToken"], + replyHandler: { response in + if let authDict = response["auth"] as? [String: Any] { + do { + let jsonData = try JSONSerialization.data(withJSONObject: authDict) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let timestamp = try container.decode(Int64.self) + return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) + } + let token = try decoder.decode(WatchToken.self, from: jsonData) + print("[TokenManager] Received token from iPhone for recovery") + resumeOnce(token) + } catch { + print("[TokenManager] Failed to decode iPhone token: \(error)") + resumeOnce(nil) + } + } else { + print("[TokenManager] iPhone returned no token for recovery") + resumeOnce(nil) + } + }, + errorHandler: { error in + print("[TokenManager] iPhone request failed: \(error)") + resumeOnce(nil) + } + ) + } + } + + if let result = await group.next() { + group.cancelAll() + return result + } + return nil + } + } + #endif + + private func refreshTokenInternal(_ token: WatchToken) async throws -> WatchToken { + let response = try await performTokenRefresh( + refreshToken: token.refreshToken, + instituteCode: token.iss + ) + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs + + let newToken = WatchToken( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + idToken: response.idToken, + iss: token.iss, + studentId: token.studentId, + studentIdNorm: token.studentIdNorm, + expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60), + tokenVersion: tokenVersion, + updatedAtMs: nowMs + ) + + try saveToken(newToken, syncToICloud: true) + + #if os(watchOS) + WatchConnectivityManager.shared.sendTokenToiPhoneInBackground() + #endif + + return newToken + } + // MARK: - Refresh Token func refreshToken() async throws -> WatchToken { guard let currentToken = loadToken() else { @@ -314,6 +600,8 @@ class TokenManager { refreshToken: currentToken.refreshToken, instituteCode: currentToken.iss ) + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs let newToken = WatchToken( accessToken: response.accessToken, @@ -322,10 +610,12 @@ class TokenManager { iss: currentToken.iss, studentId: currentToken.studentId, studentIdNorm: currentToken.studentIdNorm, - expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60) + expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60), + tokenVersion: tokenVersion, + updatedAtMs: nowMs ) - try saveToken(newToken) + try saveToken(newToken, syncToICloud: true) #if os(watchOS) WatchConnectivityManager.shared.sendTokenToiPhoneInBackground() diff --git a/firka/ios/Shared/API/iCloudTokenManager.swift b/firka/ios/Shared/API/iCloudTokenManager.swift index cda3f26..d79dbb9 100644 --- a/firka/ios/Shared/API/iCloudTokenManager.swift +++ b/firka/ios/Shared/API/iCloudTokenManager.swift @@ -12,6 +12,8 @@ class iCloudTokenManager { private let kStudentId = "firka_student_id" private let kStudentIdNorm = "firka_student_id_norm" private let kExpiryDate = "firka_expiry_date" + private let kTokenVersion = "firka_token_version" + private let kUpdatedAtMs = "firka_updated_at_ms" private let kLastUpdatedDevice = "firka_last_updated_device" private let kLastUpdateTimestamp = "firka_last_update_timestamp" @@ -39,8 +41,22 @@ 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 + } + print("[iCloud] Saving token to iCloud from \(deviceName)") + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let updatedAtMs = token.effectiveUpdatedAtMs ?? nowMs + let tokenVersion = token.effectiveTokenVersion + iCloudStore.set(token.accessToken, forKey: kAccessToken) iCloudStore.set(token.refreshToken, forKey: kRefreshToken) iCloudStore.set(token.idToken, forKey: kIdToken) @@ -48,8 +64,14 @@ class iCloudTokenManager { iCloudStore.set(token.studentId, forKey: kStudentId) iCloudStore.set(token.studentIdNorm, forKey: kStudentIdNorm) iCloudStore.set(token.expiryDate.timeIntervalSince1970, forKey: kExpiryDate) + if let tokenVersion { + iCloudStore.set(tokenVersion, forKey: kTokenVersion) + } else { + iCloudStore.removeObject(forKey: kTokenVersion) + } + iCloudStore.set(updatedAtMs, forKey: kUpdatedAtMs) iCloudStore.set(deviceName, forKey: kLastUpdatedDevice) - iCloudStore.set(Date().timeIntervalSince1970, forKey: kLastUpdateTimestamp) + iCloudStore.set(Double(updatedAtMs) / 1000.0, forKey: kLastUpdateTimestamp) let success = iCloudStore.synchronize() if success { @@ -80,6 +102,9 @@ class iCloudTokenManager { let studentIdNorm = iCloudStore.longLong(forKey: kStudentIdNorm) let expiryTimestamp = iCloudStore.double(forKey: kExpiryDate) + let tokenVersionRaw = iCloudStore.longLong(forKey: kTokenVersion) + let updatedAtMsRaw = iCloudStore.longLong(forKey: kUpdatedAtMs) + let fallbackUpdatedAt = Int64(iCloudStore.double(forKey: kLastUpdateTimestamp) * 1000.0) let lastDevice = iCloudStore.string(forKey: kLastUpdatedDevice) ?? "unknown" guard expiryTimestamp > 0 else { @@ -96,7 +121,9 @@ class iCloudTokenManager { iss: iss, studentId: studentId, studentIdNorm: studentIdNorm, - expiryDate: expiryDate + expiryDate: expiryDate, + tokenVersion: tokenVersionRaw > 0 ? tokenVersionRaw : nil, + updatedAtMs: updatedAtMsRaw > 0 ? updatedAtMsRaw : (fallbackUpdatedAt > 0 ? fallbackUpdatedAt : nil) ) let formatter = DateFormatter() @@ -121,6 +148,8 @@ class iCloudTokenManager { iCloudStore.removeObject(forKey: kStudentId) iCloudStore.removeObject(forKey: kStudentIdNorm) iCloudStore.removeObject(forKey: kExpiryDate) + iCloudStore.removeObject(forKey: kTokenVersion) + iCloudStore.removeObject(forKey: kUpdatedAtMs) iCloudStore.removeObject(forKey: kLastUpdatedDevice) iCloudStore.removeObject(forKey: kLastUpdateTimestamp) diff --git a/firka/ios/Shared/Models/WatchToken.swift b/firka/ios/Shared/Models/WatchToken.swift index 7401f52..33cf169 100644 --- a/firka/ios/Shared/Models/WatchToken.swift +++ b/firka/ios/Shared/Models/WatchToken.swift @@ -9,6 +9,30 @@ struct WatchToken: Codable { let studentId: String let studentIdNorm: Int64 let expiryDate: Date + let tokenVersion: Int64? + let updatedAtMs: Int64? + + init( + accessToken: String, + refreshToken: String, + idToken: String, + iss: String, + studentId: String, + studentIdNorm: Int64, + expiryDate: Date, + tokenVersion: Int64? = nil, + updatedAtMs: Int64? = nil + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.idToken = idToken + self.iss = iss + self.studentId = studentId + self.studentIdNorm = studentIdNorm + self.expiryDate = expiryDate + self.tokenVersion = tokenVersion + self.updatedAtMs = updatedAtMs + } enum CodingKeys: String, CodingKey { case accessToken @@ -18,5 +42,99 @@ struct WatchToken: Codable { case studentId case studentIdNorm case expiryDate + case tokenVersion + case updatedAtMs + } + + var effectiveTokenVersion: Int64? { + if let tokenVersion, tokenVersion > 0 { + return tokenVersion + } + return Self.extractIatMillis(from: idToken) + } + + var effectiveUpdatedAtMs: Int64? { + if let updatedAtMs, updatedAtMs > 0 { + return updatedAtMs + } + return nil + } + + func isSameAccount(as other: WatchToken) -> Bool { + return iss == other.iss && studentIdNorm == other.studentIdNorm + } + + func isNewer(than other: WatchToken) -> Bool { + if !isSameAccount(as: other) { + return expiryDate > other.expiryDate + } + + let incomingVersion = effectiveTokenVersion + let currentVersion = other.effectiveTokenVersion + if let incomingVersion, let currentVersion, incomingVersion != currentVersion { + return incomingVersion > currentVersion + } + + if expiryDate != other.expiryDate { + return expiryDate > other.expiryDate + } + + let incomingUpdatedAt = effectiveUpdatedAtMs + let currentUpdatedAt = other.effectiveUpdatedAtMs + if let incomingUpdatedAt, let currentUpdatedAt, incomingUpdatedAt != currentUpdatedAt { + return incomingUpdatedAt > currentUpdatedAt + } + + if refreshToken != other.refreshToken { + if let _ = incomingUpdatedAt, currentUpdatedAt == nil { + return true + } + if incomingUpdatedAt == nil, let _ = currentUpdatedAt { + return false + } + if let _ = incomingVersion, currentVersion == nil { + return true + } + if incomingVersion == nil, let _ = currentVersion { + return false + } + } + + return false + } + + static func extractIatMillis(from idToken: String) -> Int64? { + let parts = idToken.split(separator: ".") + guard parts.count >= 2 else { + return nil + } + + var payload = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = payload.count % 4 + if padding > 0 { + payload += String(repeating: "=", count: 4 - padding) + } + + guard let payloadData = Data(base64Encoded: payload), + let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] else { + return nil + } + + if let iatInt = json["iat"] as? Int { + return Int64(iatInt) * 1000 + } + if let iatInt64 = json["iat"] as? Int64 { + return iatInt64 * 1000 + } + if let iatDouble = json["iat"] as? Double { + return Int64(iatDouble * 1000) + } + if let iatString = json["iat"] as? String, let iatInt64 = Int64(iatString) { + return iatInt64 * 1000 + } + + return nil } } diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index 4898d85..57b83b6 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -76,161 +76,185 @@ class KretaClient { } static Future _setReauthFlag() async { - if (Platform.isIOS && !needsReauth) { - debugPrint('[KretaClient] Token expired, trying to recover from Watch/iCloud first...'); - - var recovered = await _tryRecoverFromWatch(); - if (recovered) { - debugPrint('[KretaClient] Successfully recovered token (attempt 1), reauth not needed'); - return; - } - - debugPrint('[KretaClient] First recovery failed, waiting for iCloud sync...'); - await Future.delayed(const Duration(milliseconds: 1500)); - - recovered = await _tryRecoverFromWatch(); - if (recovered) { - debugPrint('[KretaClient] Successfully recovered token (attempt 2), reauth not needed'); - return; - } - - debugPrint('[KretaClient] Could not recover from Watch/iCloud, setting reauth flag'); - } - + if (needsReauth) return; needsReauth = true; reauthStateNotifier.value = true; - } - - static Future _tryRecoverFromWatch() async { - if (!Platform.isIOS || !initDone) return false; - - try { - final recoveredFromiCloud = await WatchSyncHelper.checkAndRecoverFromiCloud( - isar: initData.isar, - tokens: initData.tokens, - client: initData.client, - ); - if (recoveredFromiCloud) { - debugPrint('[KretaClient] Recovered fresh token from iCloud'); - return true; - } - - await WatchSyncHelper.syncTokenFromWatch( - isar: initData.isar, - tokens: initData.tokens, - client: initData.client, - ); - - final tokens = await initData.isar.tokenModels.where().findAll(); - final token = pickActiveToken( - tokens: tokens, - settings: initData.settings, - preferredStudentIdNorm: initData.client.model.studentIdNorm, - ); - if (token == null) return false; - if (token.expiryDate == null) return false; - - if (token.expiryDate!.isAfter(DateTime.now().add(const Duration(minutes: 1)))) { - debugPrint('[KretaClient] Watch provided fresh token, expiry: ${token.expiryDate}'); - return true; - } - - return false; - } catch (e) { - debugPrint('[KretaClient] Failed to recover from Watch/iCloud: $e'); - return false; - } + debugPrint('[KretaClient] Reauth flag set'); } KretaClient(this.model, this.isar); + Future _syncTokenToAppleTargets(TokenModel token) async { + if (!Platform.isIOS) return; + if (token.accessToken == null || + token.refreshToken == null || + token.expiryDate == null) { + return; + } + + try { + await WatchSyncHelper.saveTokenToiCloud(token); + } catch (e) { + debugPrint('[KretaClient] iCloud token sync skipped: $e'); + } + + try { + await WatchSyncHelper.sendTokenToWatch(); + } catch (e) { + debugPrint('[KretaClient] Watch token sync skipped: $e'); + } + } + + Future _reloadActiveTokenModel({int? preferredStudentIdNorm}) async { + final allTokens = await isar.tokenModels.where().findAll(); + if (allTokens.isEmpty) return; + + if (initDone) { + initData.tokens = allTokens; + final selected = pickActiveToken( + tokens: allTokens, + settings: initData.settings, + preferredStudentIdNorm: preferredStudentIdNorm ?? model.studentIdNorm, + ); + if (selected != null) { + model = selected; + } + return; + } + + if (preferredStudentIdNorm != null) { + for (final token in allTokens) { + if (token.studentIdNorm == preferredStudentIdNorm) { + model = token; + return; + } + } + } + + model = allTokens.first; + } + + Future recoverToken() async { + logger.info("[Recovery] Starting central token recovery..."); + + logger.info("[Recovery] Step 1: Trying local token refresh..."); + try { + var extended = await extendToken(model); + var tokenModel = TokenModel.fromResp(extended); + + await isar.writeTxn(() async { + await isar.tokenModels.put(tokenModel); + }); + + model = tokenModel; + await _syncTokenToAppleTargets(model); + logger.info("[Recovery] Step 1 SUCCESS: Local refresh succeeded"); + return true; + } catch (e) { + logger.warning("[Recovery] Step 1 FAILED: Local refresh failed: $e"); + } + + if (!Platform.isIOS || !initDone) { + 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) + + for (var attempt = 0; attempt < retryDelays.length; attempt++) { + final delay = retryDelays[attempt]; + if (delay > 0) { + if (!iCloudHasToken && attempt > 0) { + logger.info("[Recovery] Skipping retries - iCloud has no token"); + break; + } + 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}..."); + + final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: isar, + tokens: initData.tokens, + client: this, + ); + + if (recovered) { + iCloudHasToken = true; + await _reloadActiveTokenModel(preferredStudentIdNorm: model.studentIdNorm); + + logger.info("[Recovery] Found iCloud token, trying refresh..."); + try { + var extended = await extendToken(model); + var tokenModel = TokenModel.fromResp(extended); + + await isar.writeTxn(() async { + await isar.tokenModels.put(tokenModel); + }); + + model = tokenModel; + await _syncTokenToAppleTargets(model); + 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"); + iCloudHasToken = true; + } + } else { + logger.info("[Recovery] No fresh token in iCloud on attempt ${attempt + 1}"); + if (attempt == 0) { + iCloudHasToken = false; + } + } + } + + logger.warning("[Recovery] All recovery attempts failed"); + return false; + } Future refreshTokenProactively() async { final now = timeNow(); final fiveMinutesFromNow = now.add(const Duration(minutes: 5)); - if (model.expiryDate == null || model.expiryDate!.isBefore(fiveMinutesFromNow)) { - logger.info("[Proactive] Token expired or expiring soon..."); - - if (Platform.isIOS && initDone) { - final recoveredFromiCloud = await WatchSyncHelper.checkAndRecoverFromiCloud( - isar: isar, - tokens: initData.tokens, - client: this, - ); - if (recoveredFromiCloud) { - logger.info("[Proactive] Found fresh token in iCloud, no refresh needed"); - initData.tokens = await isar.tokenModels.where().findAll(); - final activeToken = pickActiveToken( - tokens: initData.tokens, - settings: initData.settings, - preferredStudentIdNorm: model.studentIdNorm, - ); - if (activeToken != null) { - model = activeToken; - } - return true; - } - } - - logger.info("[Proactive] No fresh token in iCloud, refreshing..."); - - try { - var extended = await extendToken(model); - var tokenModel = TokenModel.fromResp(extended); - - await isar.writeTxn(() async { - await isar.tokenModels.put(tokenModel); - }); - - logger.info("[Proactive] Token refreshed successfully. New expiry: ${tokenModel.expiryDate}"); - model = tokenModel; - - if (Platform.isIOS) { - try { - await WatchSyncHelper.saveTokenToiCloud(tokenModel); - } catch (e) { - debugPrint('[KretaClient] iCloud token sync skipped: $e'); - } - - try { - await _watchChannel.invokeMethod('sendTokenToWatch', { - 'studentId': model.studentId, - 'studentIdNorm': model.studentIdNorm, - 'iss': model.iss, - 'idToken': model.idToken, - 'accessToken': model.accessToken, - 'refreshToken': model.refreshToken, - 'expiryDate': model.expiryDate!.millisecondsSinceEpoch, - }); - } catch (e) { - debugPrint('[KretaClient] Watch token sync skipped: $e'); - } - } + if (model.expiryDate == null || + model.expiryDate!.isBefore(fiveMinutesFromNow)) { + logger.info("[Proactive] Token expired or expiring soon, starting recovery..."); + final recovered = await recoverToken(); + if (recovered) { return true; - } catch (e) { - logger.warning("[Proactive] Token refresh failed: $e"); - if (_isTokenExpired(e)) { - await _setReauthFlag(); - if (Platform.isIOS && needsReauth) { - try { - _watchChannel.invokeMethod('notifyReauthRequired'); - } catch (e) { - debugPrint('[KretaClient] Watch reauth notification skipped: $e'); - } - } - } - return false; } + + logger.warning("[Proactive] Token recovery failed"); + await _setReauthFlag(); + if (Platform.isIOS && needsReauth) { + try { + _watchChannel.invokeMethod('notifyReauthRequired'); + } catch (e) { + debugPrint('[KretaClient] Watch reauth notification skipped: $e'); + } + } + return false; } - logger.fine("[Proactive] Token still valid until ${model.expiryDate}, no refresh needed"); + logger.fine( + "[Proactive] Token still valid until ${model.expiryDate}, no refresh needed"); return true; } Future _mutexCallback(Future Function() callback) async { + const maxWaitTime = Duration(seconds: 30); + final startTime = DateTime.now(); + while (_tokenMutex) { + if (DateTime.now().difference(startTime) > maxWaitTime) { + logger.warning("[Mutex] Timeout waiting for token mutex, forcing release"); + _tokenMutex = false; + break; + } await Future.delayed(const Duration(milliseconds: 50)); } _tokenMutex = true; @@ -247,38 +271,13 @@ class KretaClient { if (now.millisecondsSinceEpoch >= model.expiryDate!.millisecondsSinceEpoch) { - logger.info("Token expired at ${model.expiryDate}, refreshing for user: ${model.studentId}"); - var extended = await extendToken(model); - var tokenModel = TokenModel.fromResp(extended); + logger.info( + "Token expired at ${model.expiryDate}, starting recovery for user: ${model.studentId}"); - await isar.writeTxn(() async { - await isar.tokenModels.put(tokenModel); - }); - - logger.info("Token refreshed successfully. New expiry: ${tokenModel.expiryDate}"); - - model = tokenModel; - - if (Platform.isIOS) { - try { - await WatchSyncHelper.saveTokenToiCloud(tokenModel); - } catch (e) { - debugPrint('[KretaClient] iCloud token sync skipped: $e'); - } - - try { - await _watchChannel.invokeMethod('sendTokenToWatch', { - 'studentId': model.studentId, - 'studentIdNorm': model.studentIdNorm, - 'iss': model.iss, - 'idToken': model.idToken, - 'accessToken': model.accessToken, - 'refreshToken': model.refreshToken, - 'expiryDate': model.expiryDate!.millisecondsSinceEpoch, - }); - } catch (e) { - debugPrint('[KretaClient] Watch token sync skipped: $e'); - } + final recovered = await recoverToken(); + if (!recovered) { + logger.warning("Token recovery failed for user: ${model.studentId}"); + throw TokenExpiredException(); } } @@ -313,7 +312,8 @@ class KretaClient { if (responseData == null || (responseData is List && responseData.isEmpty) || (responseData is Map && responseData.isEmpty)) { - logger.warning("API returned ${resp.statusCode} with empty data for: $url - possible stale session"); + logger.warning( + "API returned ${resp.statusCode} with empty data for: $url - possible stale session"); } } } catch (ex) { @@ -672,7 +672,8 @@ class KretaClient { } catch (ex) { if (_isTokenExpired(ex)) { await _setReauthFlag(); - logger.warning("Token expired in timed request, setting needsReauth flag"); + logger.warning( + "Token expired in timed request, setting needsReauth flag"); } if (cache != null) { @@ -811,7 +812,8 @@ class KretaClient { return ApiResponse(lessons, 200, err, cached); } - Future>> getLessons({bool forceCache = true}) async { + Future>> getLessons( + {bool forceCache = true}) async { var (resp, status, ex, cached) = await _cachingGet( CacheId.getLessons, KretaEndpoints.getLessons(model.iss!), diff --git a/firka/lib/helpers/api/token_grant.dart b/firka/lib/helpers/api/token_grant.dart index 7306193..dae89af 100644 --- a/firka/lib/helpers/api/token_grant.dart +++ b/firka/lib/helpers/api/token_grant.dart @@ -1,14 +1,9 @@ -import 'dart:io'; - import 'package:dio/dio.dart'; import 'package:firka/helpers/api/exceptions/token.dart'; import 'package:firka/helpers/api/resp/token_grant.dart'; import 'package:firka/helpers/db/models/token_model.dart'; -import 'package:flutter/foundation.dart'; import '../../main.dart'; -import '../active_account_helper.dart'; -import '../watch_sync_helper.dart'; import 'consts.dart'; Future getAccessToken(String code) async { @@ -48,7 +43,8 @@ Future getAccessToken(String code) async { const _tokenRefreshRetryDelays = [1000, 3000, 5000]; Future extendToken(TokenModel model) async { - logger.info("Extending token for user: ${model.studentId}, institute: ${model.iss}"); + logger.info( + "Extending token for user: ${model.studentId}, institute: ${model.iss}"); final headers = { "content-type": "application/x-www-form-urlencoded; charset=UTF-8", @@ -69,7 +65,8 @@ Future extendToken(TokenModel model) async { try { if (attempt > 0) { final delay = _tokenRefreshRetryDelays[attempt - 1]; - logger.info("Token refresh attempt ${attempt + 1}, waiting ${delay}ms..."); + logger.info( + "Token refresh attempt ${attempt + 1}, waiting ${delay}ms..."); await Future.delayed(Duration(milliseconds: delay)); } @@ -78,43 +75,21 @@ Future extendToken(TokenModel model) async { switch (response.statusCode) { case 200: - logger.info("Token extended successfully for user: ${model.studentId}"); + logger + .info("Token extended successfully for user: ${model.studentId}"); return TokenGrantResponse.fromJson(response.data); case 400: case 401: - if (Platform.isIOS && initDone) { - debugPrint('[TokenGrant] Got ${response.statusCode}, checking iCloud for fresher token...'); - final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud( - isar: initData.isar, - tokens: initData.tokens, - client: initData.client, - ); - if (recovered) { - debugPrint('[TokenGrant] Found fresher token in iCloud! Using it instead of failing.'); - final freshToken = pickActiveToken( - tokens: initData.tokens, - settings: initData.settings, - preferredStudentIdNorm: model.studentIdNorm, - ); - if (freshToken == null) { - throw TokenExpiredException(); - } - return TokenGrantResponse( - accessToken: freshToken.accessToken!, - refreshToken: freshToken.refreshToken!, - idToken: freshToken.idToken ?? '', - expiresIn: freshToken.expiryDate!.difference(DateTime.now()).inSeconds, - tokenType: 'Bearer', - scope: 'openid', - ); - } - debugPrint('[TokenGrant] No fresher token in iCloud, token truly expired'); - } - logger.warning("Token refresh failed (${response.statusCode}) - refresh token invalid for user: ${model.studentId}"); - throw response.statusCode == 400 ? TokenExpiredException() : InvalidGrantException(); + logger.warning( + "Token refresh failed (${response.statusCode}) - refresh token invalid for user: ${model.studentId}"); + throw response.statusCode == 400 + ? TokenExpiredException() + : InvalidGrantException(); default: - logger.warning("Token refresh failed (${response.statusCode}) for user: ${model.studentId}, attempt ${attempt + 1}"); - lastError = Exception("Failed to get access token, response code: ${response.statusCode}"); + logger.warning( + "Token refresh failed (${response.statusCode}) for user: ${model.studentId}, attempt ${attempt + 1}"); + lastError = Exception( + "Failed to get access token, response code: ${response.statusCode}"); // Continue to retry for network errors continue; } @@ -123,7 +98,8 @@ Future extendToken(TokenModel model) async { } on InvalidGrantException { rethrow; } on DioException catch (e) { - logger.warning("Token refresh network error for user: ${model.studentId}, attempt ${attempt + 1}: $e"); + logger.warning( + "Token refresh network error for user: ${model.studentId}, attempt ${attempt + 1}: $e"); lastError = e; continue; } catch (e) { @@ -133,6 +109,7 @@ Future extendToken(TokenModel model) async { } } - logger.severe("All token refresh attempts failed for user: ${model.studentId}"); + logger + .severe("All token refresh attempts failed for user: ${model.studentId}"); throw lastError ?? Exception("Token refresh failed after all retries"); } diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index dd7a4b8..7d147f7 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -14,6 +15,26 @@ class WatchSyncHelper { static const _watchChannel = MethodChannel('app.firka/watch_sync'); static bool _initialized = false; + /// Invoke method with timeout to prevent infinite blocking + static Future _invokeMethodWithTimeout( + String method, [ + dynamic arguments, + Duration timeout = const Duration(seconds: 10), + ]) async { + try { + return await _watchChannel + .invokeMethod(method, arguments) + .timeout(timeout, onTimeout: () { + debugPrint( + '[WatchSync] Timeout calling $method after ${timeout.inSeconds}s'); + return null; + }); + } catch (e) { + debugPrint('[WatchSync] Error calling $method: $e'); + return null; + } + } + static TokenModel? _resolveCurrentToken({ List? tokens, KretaClient? client, @@ -50,6 +71,113 @@ class WatchSyncHelper { return _resolveCurrentToken(tokens: tokens, client: client)?.studentIdNorm; } + static int? _asInt(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + static int? _extractTokenVersionFromIdToken(String? idToken) { + if (idToken == null || idToken.isEmpty) return null; + final parts = idToken.split('.'); + if (parts.length < 2) return null; + + try { + final payloadJson = + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); + final payload = jsonDecode(payloadJson); + if (payload is! Map) return null; + final iatSeconds = _asInt(payload['iat']); + if (iatSeconds == null || iatSeconds <= 0) return null; + return iatSeconds * 1000; + } catch (_) { + return null; + } + } + + 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 bool _isIncomingTokenNewerThanCurrent({ + required DateTime incomingExpiry, + required String? incomingIdToken, + required String? incomingRefreshToken, + required int? incomingTokenVersion, + required int? incomingUpdatedAtMs, + required TokenModel currentToken, + }) { + final currentExpiry = currentToken.expiryDate; + if (currentExpiry == null) { + return true; + } + + final incomingVersion = + incomingTokenVersion ?? _extractTokenVersionFromIdToken(incomingIdToken); + final currentVersion = _extractTokenVersionFromIdToken(currentToken.idToken); + + if (incomingVersion != null && + currentVersion != null && + incomingVersion != currentVersion) { + return incomingVersion > currentVersion; + } + + if (incomingExpiry.isAfter(currentExpiry)) { + return true; + } + if (incomingExpiry.isBefore(currentExpiry)) { + return false; + } + + if (incomingVersion != null && currentVersion == null) { + return true; + } + if (incomingVersion == null && currentVersion != null) { + return false; + } + + final currentRefresh = currentToken.refreshToken; + if (incomingRefreshToken != null && + currentRefresh != null && + incomingRefreshToken != currentRefresh) { + if (incomingUpdatedAtMs != null && incomingUpdatedAtMs > 0) { + return true; + } + return incomingIdToken != null && incomingIdToken != currentToken.idToken; + } + + return false; + } + + static Map _buildTokenSyncPayload( + TokenModel token, { + bool includeSentAt = false, + }) { + final nowMs = DateTime.now().millisecondsSinceEpoch; + final payload = { + 'studentId': token.studentId, + 'studentIdNorm': token.studentIdNorm, + 'iss': token.iss, + 'idToken': token.idToken, + 'accessToken': token.accessToken, + 'refreshToken': token.refreshToken, + 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, + 'tokenVersion': _resolveTokenVersionForSend(token), + 'updatedAtMs': nowMs, + }; + if (includeSentAt) { + payload['sentAtMs'] = nowMs; + } + return payload; + } + static void initialize() { if (!Platform.isIOS) return; if (_initialized) return; @@ -72,7 +200,8 @@ class WatchSyncHelper { debugPrint('[WatchSync] Token received from Watch'); return await _processTokenFromWatch(call.arguments); case 'onTokenRecoveredFromiCloud': - debugPrint('[WatchSync] Token recovered from iCloud notification received'); + debugPrint( + '[WatchSync] Token recovered from iCloud notification received'); await _handleTokenRecoveredFromiCloud(); return null; default: @@ -84,7 +213,8 @@ class WatchSyncHelper { /// This clears the reauth flag if it was set, since we now have a valid token static Future _handleTokenRecoveredFromiCloud() async { if (!initDone) { - debugPrint('[WatchSync] Cannot handle iCloud recovery: app not initialized'); + debugPrint( + '[WatchSync] Cannot handle iCloud recovery: app not initialized'); return; } @@ -96,7 +226,8 @@ class WatchSyncHelper { ); if (recovered) { - debugPrint('[WatchSync] Token recovered from iCloud, reauth flag cleared'); + debugPrint( + '[WatchSync] Token recovered from iCloud, reauth flag cleared'); } else { final token = pickActiveToken( tokens: initData.tokens, @@ -105,7 +236,8 @@ class WatchSyncHelper { final expiryDate = token?.expiryDate; if (expiryDate != null && expiryDate.isAfter(DateTime.now())) { KretaClient.clearReauthFlag(); - debugPrint('[WatchSync] Cleared reauth flag after iCloud notification (token is valid)'); + debugPrint( + '[WatchSync] Cleared reauth flag after iCloud notification (token is valid)'); } } } catch (e) { @@ -140,15 +272,7 @@ class WatchSyncHelper { return {'error': 'needsReauth'}; } - final tokenData = { - 'studentId': token.studentId, - 'studentIdNorm': token.studentIdNorm, - 'iss': token.iss, - 'idToken': token.idToken, - 'accessToken': token.accessToken, - 'refreshToken': token.refreshToken, - 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, - }; + final tokenData = _buildTokenSyncPayload(token, includeSentAt: true); debugPrint('[WatchSync] Returning token for Watch'); return tokenData; @@ -160,15 +284,19 @@ class WatchSyncHelper { final tokenData = _getTokenForWatch(); if (tokenData == null) return; - try { - await _watchChannel.invokeMethod('sendTokenToWatch', tokenData); - debugPrint('[WatchSync] Token sent to Watch'); - } catch (e) { - debugPrint('[WatchSync] Failed to send token: $e'); - } + await _invokeMethodWithTimeout('sendTokenToWatch', tokenData); + debugPrint('[WatchSync] Token send requested to Watch (async delivery)'); } - static Future> _processTokenFromWatch(dynamic arguments) async { + /// Sends a specific token directly to Watch. + /// Useful during app initialization before global init state is fully ready. + static Future sendTokenModelToWatch(TokenModel token) async { + if (!Platform.isIOS) return; + await _sendTokenToWatchInternal(token); + } + + static Future> _processTokenFromWatch( + dynamic arguments) async { if (!initDone) { debugPrint('[WatchSync] Cannot process Watch token: app not initialized'); return {'success': false, 'error': 'not_initialized'}; @@ -193,15 +321,38 @@ class WatchSyncHelper { tokens: initData.tokens, client: initData.client, ); + final currentToken = _resolveCurrentToken( + tokens: initData.tokens, + client: initData.client, + ); final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); + final watchTokenVersion = _resolveIncomingTokenVersion(tokenData); + final watchUpdatedAtMs = _asInt(tokenData['updatedAtMs']); + final watchIdToken = tokenData['idToken'] as String?; + final watchRefreshToken = tokenData['refreshToken'] as String?; - if (watchExpiryDate.isBefore(DateTime.now())) { - debugPrint('[WatchSync] Watch token is expired'); - return {'success': false, 'error': 'token_expired'}; + final isForActiveAccount = expectedStudentIdNorm == null || + watchStudentIdNorm == expectedStudentIdNorm; + if (isForActiveAccount && + currentToken != null && + currentToken.studentIdNorm == watchStudentIdNorm) { + if (!_isIncomingTokenNewerThanCurrent( + incomingExpiry: watchExpiryDate, + incomingIdToken: watchIdToken, + incomingRefreshToken: watchRefreshToken, + incomingTokenVersion: watchTokenVersion, + incomingUpdatedAtMs: watchUpdatedAtMs, + currentToken: currentToken, + )) { + debugPrint( + '[WatchSync] Ignoring stale token from Watch for active account. Incoming expiry: $watchExpiryDate, incomingVersion: $watchTokenVersion'); + return {'success': false, 'error': 'stale_token'}; + } } - debugPrint('[WatchSync] Accepting token from Watch, expiry: $watchExpiryDate'); + debugPrint( + '[WatchSync] Accepting token from Watch, expiry: $watchExpiryDate (expired: ${watchExpiryDate.isBefore(DateTime.now())})'); final newToken = TokenModel.fromValues( watchStudentIdNorm, @@ -218,8 +369,6 @@ class WatchSyncHelper { }); initData.tokens = await initData.isar.tokenModels.where().findAll(); - final isForActiveAccount = expectedStudentIdNorm == null || - watchStudentIdNorm == expectedStudentIdNorm; if (isForActiveAccount) { initData.client.model = newToken; KretaClient.clearReauthFlag(); @@ -246,22 +395,10 @@ class WatchSyncHelper { return; } - final tokenData = { - 'studentId': token.studentId, - 'studentIdNorm': token.studentIdNorm, - 'iss': token.iss, - 'idToken': token.idToken, - 'accessToken': token.accessToken, - 'refreshToken': token.refreshToken, - 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, - }; + final tokenData = _buildTokenSyncPayload(token, includeSentAt: true); - try { - await _watchChannel.invokeMethod('sendTokenToWatch', tokenData); - debugPrint('[WatchSync] iPhone token sent to Watch'); - } catch (e) { - debugPrint('[WatchSync] Failed to send token to Watch: $e'); - } + await _invokeMethodWithTimeout('sendTokenToWatch', tokenData); + debugPrint('[WatchSync] iPhone token sent to Watch (or timeout)'); } static String? _getLanguageForWatch() { @@ -281,12 +418,9 @@ class WatchSyncHelper { final languageCode = _getLanguageForWatch(); if (languageCode == null) return; - try { - await _watchChannel.invokeMethod('sendLanguageToWatch', languageCode); - debugPrint('[WatchSync] Language sent to Watch: $languageCode'); - } catch (e) { - debugPrint('[WatchSync] Failed to send language: $e'); - } + await _invokeMethodWithTimeout('sendLanguageToWatch', languageCode); + debugPrint( + '[WatchSync] Language sent to Watch: $languageCode (or timeout)'); } /// Check iCloud for a fresher token and update local storage if found. @@ -310,10 +444,11 @@ class WatchSyncHelper { try { debugPrint('[WatchSync] Checking iCloud for fresher token...'); - final result = await _watchChannel.invokeMethod('checkiCloudToken'); + final result = await _invokeMethodWithTimeout( + 'checkiCloudToken', null, const Duration(seconds: 5)); if (result == null) { - debugPrint('[WatchSync] No response from native'); + debugPrint('[WatchSync] No response from native (timeout or error)'); return false; } @@ -329,7 +464,8 @@ class WatchSyncHelper { ); final iCloudStudentIdNorm = tokenData['studentIdNorm'] as int?; - if (expectedStudentIdNorm != null && iCloudStudentIdNorm != expectedStudentIdNorm) { + if (expectedStudentIdNorm != null && + iCloudStudentIdNorm != expectedStudentIdNorm) { debugPrint( '[WatchSync] iCloud token belongs to different account ($iCloudStudentIdNorm), active is $expectedStudentIdNorm - ignoring'); return false; @@ -341,21 +477,32 @@ class WatchSyncHelper { return false; } - final iCloudExpiryDate = DateTime.fromMillisecondsSinceEpoch(iCloudExpiry); - - if (iCloudExpiryDate.isBefore(DateTime.now())) { - debugPrint('[WatchSync] iCloud token is expired'); - return false; - } + final iCloudExpiryDate = + DateTime.fromMillisecondsSinceEpoch(iCloudExpiry); + final iCloudTokenVersion = _resolveIncomingTokenVersion(tokenData); + final iCloudUpdatedAtMs = _asInt(tokenData['updatedAtMs']); + final iCloudIdToken = tokenData['idToken'] as String?; + final iCloudRefreshToken = tokenData['refreshToken'] as String?; final currentToken = _resolveCurrentToken( tokens: effectiveTokens, client: effectiveClient, ); final localExpiry = currentToken?.expiryDate; + final shouldAccept = currentToken == null + ? true + : _isIncomingTokenNewerThanCurrent( + incomingExpiry: iCloudExpiryDate, + incomingIdToken: iCloudIdToken, + incomingRefreshToken: iCloudRefreshToken, + incomingTokenVersion: iCloudTokenVersion, + incomingUpdatedAtMs: iCloudUpdatedAtMs, + currentToken: currentToken, + ); - if (localExpiry == null || iCloudExpiryDate.isAfter(localExpiry)) { - debugPrint('[WatchSync] iCloud has fresher token! iCloud: $iCloudExpiryDate, Local: $localExpiry'); + if (shouldAccept) { + debugPrint( + '[WatchSync] iCloud has fresher token! iCloud: $iCloudExpiryDate, Local: $localExpiry, iCloudVersion: $iCloudTokenVersion'); final newToken = TokenModel.fromValues( (tokenData['studentIdNorm'] as int?) ?? 0, @@ -388,10 +535,12 @@ class WatchSyncHelper { KretaClient.clearReauthFlag(); } - debugPrint('[WatchSync] Token recovered from iCloud! New expiry: $iCloudExpiryDate'); + debugPrint( + '[WatchSync] Token recovered from iCloud! New expiry: $iCloudExpiryDate'); return true; } else { - debugPrint('[WatchSync] Local token is same or fresher. Local: $localExpiry, iCloud: $iCloudExpiryDate'); + debugPrint( + '[WatchSync] Local token is same or fresher. Local: $localExpiry, iCloud: $iCloudExpiryDate'); return false; } } catch (e) { @@ -411,22 +560,11 @@ class WatchSyncHelper { return; } - final tokenData = { - 'studentId': token.studentId, - 'studentIdNorm': token.studentIdNorm, - 'iss': token.iss, - 'idToken': token.idToken, - 'accessToken': token.accessToken, - 'refreshToken': token.refreshToken, - 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, - }; + final tokenData = _buildTokenSyncPayload(token); - try { - await _watchChannel.invokeMethod('saveTokeToniCloud', tokenData); - debugPrint('[WatchSync] Token saved to iCloud'); - } catch (e) { - debugPrint('[WatchSync] Failed to save token to iCloud: $e'); - } + await _invokeMethodWithTimeout( + 'saveTokeToniCloud', tokenData, const Duration(seconds: 5)); + debugPrint('[WatchSync] Token saved to iCloud (or timeout)'); } static Future syncTokenFromWatch({ @@ -447,7 +585,8 @@ class WatchSyncHelper { try { debugPrint('[WatchSync] Requesting token from Watch...'); - final result = await _watchChannel.invokeMethod('requestTokenFromWatch'); + final result = await _invokeMethodWithTimeout( + 'requestTokenFromWatch', null, const Duration(seconds: 10)); final expectedStudentIdNorm = _resolveExpectedStudentIdNorm( tokens: effectiveTokens, client: effectiveClient, @@ -478,7 +617,8 @@ class WatchSyncHelper { currentToken.refreshToken != null && currentToken.expiryDate != null && !KretaClient.needsReauth) { - debugPrint('[WatchSync] Sending iPhone token to Watch (Watch has no token)'); + debugPrint( + '[WatchSync] Sending iPhone token to Watch (Watch has no token)'); await _sendTokenToWatchInternal(currentToken); } return; @@ -496,7 +636,8 @@ class WatchSyncHelper { return; } - if (expectedStudentIdNorm != null && watchStudentIdNorm != expectedStudentIdNorm) { + if (expectedStudentIdNorm != null && + watchStudentIdNorm != expectedStudentIdNorm) { debugPrint( '[WatchSync] Watch token belongs to different account ($watchStudentIdNorm), active is $expectedStudentIdNorm - keeping active account'); if (currentToken != null && @@ -510,9 +651,21 @@ class WatchSyncHelper { } final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); - - final currentExpiry = currentToken?.expiryDate; - if (currentExpiry == null || watchExpiryDate.isAfter(currentExpiry)) { + final watchTokenVersion = _resolveIncomingTokenVersion(tokenData); + final watchUpdatedAtMs = _asInt(tokenData['updatedAtMs']); + final watchIdToken = tokenData['idToken'] as String?; + final watchRefreshToken = tokenData['refreshToken'] as String?; + final shouldAccept = currentToken == null + ? true + : _isIncomingTokenNewerThanCurrent( + incomingExpiry: watchExpiryDate, + incomingIdToken: watchIdToken, + incomingRefreshToken: watchRefreshToken, + incomingTokenVersion: watchTokenVersion, + incomingUpdatedAtMs: watchUpdatedAtMs, + currentToken: currentToken, + ); + if (shouldAccept) { debugPrint('[WatchSync] Watch has newer token, updating iPhone'); final newToken = TokenModel.fromValues( tokenData['studentIdNorm'] as int, @@ -545,13 +698,12 @@ class WatchSyncHelper { KretaClient.clearReauthFlag(); } - debugPrint('[WatchSync] Token updated from Watch. New expiry: $watchExpiryDate'); + debugPrint( + '[WatchSync] Token updated from Watch. New expiry: $watchExpiryDate'); } else { - debugPrint('[WatchSync] iPhone token is same or newer, sending to Watch'); - final tokenToSend = currentToken; - if (tokenToSend != null) { - await _sendTokenToWatchInternal(tokenToSend); - } + debugPrint( + '[WatchSync] iPhone token is same or newer, sending to Watch'); + await _sendTokenToWatchInternal(currentToken); } } catch (e) { debugPrint('[WatchSync] Failed to sync token from Watch: $e'); diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 4a2ddc7..2a40779 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -8,7 +8,6 @@ import 'package:firka/helpers/db/models/app_settings_model.dart'; import 'package:firka/helpers/db/models/generic_cache_model.dart'; import 'package:firka/helpers/db/models/timetable_cache_model.dart'; import 'package:firka/helpers/db/models/token_model.dart'; -import 'package:firka/helpers/db/widget.dart'; import 'package:firka/helpers/extensions.dart'; import 'package:firka/helpers/firka_bundle.dart'; import 'package:firka/helpers/settings.dart'; @@ -213,86 +212,26 @@ Future _initData(AppInitialization init) async { init.tokens = allTokens; final token = pickActiveToken(tokens: allTokens, settings: init.settings); if (token == null) { - logger.warning("[Init] Tokens disappeared during initialization; skipping client setup"); + logger.warning( + "[Init] Tokens disappeared during initialization; skipping client setup"); return; } logger.fine("Initializing kréta client as: ${token.studentId}"); init.client = KretaClient(token, init.isar); if (Platform.isIOS) { - final recoveredFromiCloud = await WatchSyncHelper.checkAndRecoverFromiCloud( - isar: init.isar, - tokens: init.tokens, - client: init.client, - ); - if (recoveredFromiCloud) { - init.tokens = await init.isar.tokenModels.where().findAll(); - final activeToken = pickActiveToken( - tokens: init.tokens, - settings: init.settings, - preferredStudentIdNorm: init.client.model.studentIdNorm, - ); - if (activeToken != null) { - init.client.model = activeToken; - } - logger.info('[Init] Recovered fresher token from iCloud (immediate)'); + final expiryDate = token.expiryDate; + if (expiryDate != null) { + KretaClient.clearReauthFlag(); } - await Future.delayed(const Duration(milliseconds: 300)); - await WatchSyncHelper.syncTokenFromWatch( - isar: init.isar, - tokens: init.tokens, - client: init.client, - ); - init.tokens = await init.isar.tokenModels.where().findAll(); - final activeToken = pickActiveToken( - tokens: init.tokens, - settings: init.settings, - preferredStudentIdNorm: init.client.model.studentIdNorm, - ); - if (activeToken != null) { - init.client.model = activeToken; - } - } - - await init.client.refreshTokenProactively(); - - if (Platform.isIOS) { - final selectedToken = pickActiveToken( - tokens: init.tokens, - settings: init.settings, - preferredStudentIdNorm: init.client.model.studentIdNorm, - ); - if (selectedToken != null) { + unawaited(() async { try { - await WatchSyncHelper.saveTokenToiCloud(selectedToken); + await WatchSyncHelper.sendTokenModelToWatch(token); } catch (e) { - logger.warning('[Init] Failed to push active token to iCloud: $e'); + logger.warning('[Init] Failed to sync active token to Watch: $e'); } - } - try { - await WatchSyncHelper.sendTokenToWatch(); - } catch (e) { - logger.warning('[Init] Failed to push active token to Watch: $e'); - } - } - - await WidgetCacheHelper.updateWidgetCache(appStyle, init.client); - - if (Platform.isIOS) { - await WidgetCacheHelper.refreshIOSWidgets(init.client, init.settings); - } - - if (Platform.isIOS) { - final studentName = token.studentId ?? "Student"; - - LiveActivityService.onUserLogin( - client: init.client, - studentName: studentName, - settingsStore: init.settings, - ).catchError((e, st) { - logger.severe('LiveActivity registration failed: $e', e, st); - }); + }()); } } @@ -351,7 +290,6 @@ Future initializeApp() async { if (Platform.isIOS) { try { await LiveActivityService.initialize(); - } catch (e, st) { logger.severe('Failed to initialize LiveActivity: $e', e, st); } diff --git a/firka/lib/ui/phone/pages/home/home_main.dart b/firka/lib/ui/phone/pages/home/home_main.dart index 5ef8d34..1f715b8 100644 --- a/firka/lib/ui/phone/pages/home/home_main.dart +++ b/firka/lib/ui/phone/pages/home/home_main.dart @@ -162,6 +162,8 @@ class _HomeMainScreen extends FirkaState { }); final r = cacheOnly ? 1 : 2; + final startTime = DateTime.now(); + const maxWaitTime = Duration(seconds: 30); while (lessonsFetched < r || noticeBoardFetched < r || @@ -170,6 +172,10 @@ class _HomeMainScreen extends FirkaState { testsFetched < r || gradesFetched < r || homeworkFetched < r) { + if (DateTime.now().difference(startTime) > maxWaitTime) { + debugPrint('[HomeMain] Data fetch timed out after 30s'); + break; + } await Future.delayed(Duration(milliseconds: 50)); } } diff --git a/firka/lib/ui/phone/pages/home/home_timetable.dart b/firka/lib/ui/phone/pages/home/home_timetable.dart index 4fe3f11..f39e8c1 100644 --- a/firka/lib/ui/phone/pages/home/home_timetable.dart +++ b/firka/lib/ui/phone/pages/home/home_timetable.dart @@ -213,14 +213,31 @@ class _HomeTimetableScreen extends FirkaState testsFetched++; }); + final startTime = DateTime.now(); + const maxWaitTime = Duration(seconds: 15); + while (lessonsFetched < 1 || testsFetched < 1) { + if (DateTime.now().difference(startTime) > maxWaitTime) { + debugPrint('[Timetable] Cache load timed out after 15s'); + return; + } await Future.delayed(Duration(milliseconds: 50)); } await _updateState(now, lessonsResp!, testsResp!); - while (lessonsFetched < 2 || testsFetched < 2) { - await Future.delayed(Duration(milliseconds: 50)); + + if (!forceCache) { + final networkStartTime = DateTime.now(); + while (lessonsFetched < 2 || testsFetched < 2) { + if (DateTime.now().difference(networkStartTime) > maxWaitTime) { + debugPrint('[Timetable] Network load timed out after 15s'); + break; + } + await Future.delayed(Duration(milliseconds: 50)); + } + if (lessonsFetched >= 2 && testsFetched >= 2) { + await _updateState(now, lessonsResp!, testsResp!); + } } - await _updateState(now, lessonsResp!, testsResp!); } void updateListener() async { diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index 67783ae..28867fb 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -1,10 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:isar/isar.dart'; - import 'package:firka/helpers/api/client/kreta_client.dart'; -import 'package:firka/helpers/db/models/token_model.dart'; import 'package:firka/helpers/api/client/kreta_stream.dart'; import 'package:firka/helpers/api/exceptions/token.dart'; import 'package:firka/helpers/active_account_helper.dart'; @@ -34,7 +31,6 @@ import '../../../../helpers/debug_helper.dart'; import '../../../../helpers/firka_bundle.dart'; import '../../../../helpers/firka_state.dart'; import '../../../../helpers/image_preloader.dart'; -import '../../../../helpers/watch_sync_helper.dart'; import '../../../widget/delayed_spinner.dart'; import '../../../widget/firka_icon.dart'; import '../../pages/extras/extras.dart'; @@ -211,48 +207,16 @@ class _HomeScreenState extends FirkaState { try { _prefetched = true; - if (Platform.isIOS) { - final token = pickActiveToken( - tokens: widget.data.tokens, - settings: widget.data.settings, - preferredStudentIdNorm: widget.data.client.model.studentIdNorm, + try { + await widget.data.client.refreshTokenProactively().timeout( + const Duration(seconds: 60), + onTimeout: () { + logger.warning('[Home] Token refresh/recovery timed out after 60s'); + return false; + }, ); - final tokenExpiry = token?.expiryDate; - final isTokenExpiredOrExpiring = tokenExpiry == null || - tokenExpiry.isBefore(DateTime.now().add(const Duration(minutes: 5))); - - if (isTokenExpiredOrExpiring || KretaClient.needsReauth) { - logger.info('[Home] Token expired/expiring or needsReauth, trying iCloud recovery...'); - - const delays = [1, 5, 10, 5, 10]; - - for (int attempt = 0; attempt < delays.length; attempt++) { - await Future.delayed(Duration(seconds: delays[attempt])); - - final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud( - isar: widget.data.isar, - tokens: widget.data.tokens, - client: widget.data.client, - ); - - if (recovered) { - widget.data.tokens = await widget.data.isar.tokenModels.where().findAll(); - final activeToken = pickActiveToken( - tokens: widget.data.tokens, - settings: widget.data.settings, - preferredStudentIdNorm: widget.data.client.model.studentIdNorm, - ); - if (activeToken != null) { - widget.data.client.model = activeToken; - } - KretaClient.clearReauthFlag(); - logger.info('[Home] Recovered token from iCloud (attempt ${attempt + 1}, after ${delays[attempt]}s)'); - break; - } - - logger.fine('[Home] iCloud check attempt ${attempt + 1} (after ${delays[attempt]}s): no fresher token yet'); - } - } + } catch (e) { + logger.warning('[Home] Token refresh/recovery failed: $e'); } await fetchData(); @@ -264,10 +228,25 @@ class _HomeScreenState extends FirkaState { } if (Platform.isIOS) { - await WidgetCacheHelper.refreshIOSWidgets(widget.data.client, widget.data.settings); + await WidgetCacheHelper.refreshIOSWidgets( + widget.data.client, widget.data.settings); + + final token = pickActiveToken( + tokens: widget.data.tokens, + settings: widget.data.settings, + ); + final studentName = token?.studentId ?? "Student"; + LiveActivityService.onUserLogin( + client: widget.data.client, + studentName: studentName, + settingsStore: widget.data.settings, + ).catchError((e, st) { + logger.severe('LiveActivity registration failed: $e', e, st); + }); } - if (!_disposed && (LiveActivityService.isTokenExpired || KretaClient.needsReauth)) { + if (!_disposed && + (LiveActivityService.isTokenExpired || KretaClient.needsReauth)) { activeToast = ActiveToastType.reauth; setState(() { toast = buildReauthToast(context, widget.data, () { @@ -281,7 +260,6 @@ class _HomeScreenState extends FirkaState { }); return; } - } catch (e) { if (e is TokenExpiredException || e is InvalidGrantException) { activeToast = ActiveToastType.reauth; @@ -413,6 +391,9 @@ class _HomeScreenState extends FirkaState { homeworkFetched++; }); + final startTime = DateTime.now(); + const maxWaitTime = Duration(seconds: 30); + while (lessonsFetched < 2 || noticeBoardFetched < 2 || infoBoardFetched < 2 || @@ -420,6 +401,10 @@ class _HomeScreenState extends FirkaState { testsFetched < 2 || gradesFetched < 2 || homeworkFetched < 2) { + if (DateTime.now().difference(startTime) > maxWaitTime) { + logger.warning('[Home] fetchData timed out after 30s'); + break; + } await Future.delayed(Duration(milliseconds: 50)); } } @@ -443,7 +428,8 @@ class _HomeScreenState extends FirkaState { prefetch(); _preloadImages(); - if (Platform.isIOS && widget.data.settings.group("settings").boolean("beta_warning")) { + if (Platform.isIOS && + widget.data.settings.group("settings").boolean("beta_warning")) { Future.delayed(Duration(seconds: 5), () async { await LiveActivityService.showConsentScreenIfNeeded(); }); diff --git a/firka/lib/ui/phone/widgets/login_webview.dart b/firka/lib/ui/phone/widgets/login_webview.dart index b6cf78e..1f4d668 100644 --- a/firka/lib/ui/phone/widgets/login_webview.dart +++ b/firka/lib/ui/phone/widgets/login_webview.dart @@ -9,6 +9,7 @@ import 'package:isar/isar.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../../../helpers/api/client/kreta_client.dart'; +import '../../../helpers/watch_sync_helper.dart'; import '../../../helpers/api/consts.dart'; import '../../../helpers/api/token_grant.dart'; import '../../../helpers/db/models/token_model.dart'; @@ -89,17 +90,12 @@ class _LoginWebviewWidgetState extends FirkaState { await accountPicker.postUpdate(); if (Platform.isIOS) { - const watchChannel = MethodChannel('app.firka/watch_sync'); try { - await watchChannel.invokeMethod('sendTokenToWatch', { - 'studentId': tokenModel.studentId, - 'studentIdNorm': tokenModel.studentIdNorm, - 'iss': tokenModel.iss, - 'idToken': tokenModel.idToken, - 'accessToken': tokenModel.accessToken, - 'refreshToken': tokenModel.refreshToken, - 'expiryDate': tokenModel.expiryDate!.millisecondsSinceEpoch, - }); + await WatchSyncHelper.saveTokenToiCloud(tokenModel); + } catch (_) {} + + try { + await WatchSyncHelper.sendTokenToWatch(); } catch (e) { // Watch may not be available, ignore }