Token recovery and sync refactor; LiveActivity UI

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.
This commit is contained in:
Horváth Gergely
2026-02-11 10:37:14 +01:00
committed by 4831c0
parent 6c67d22fb8
commit 0b78712e64
18 changed files with 1449 additions and 675 deletions

View File

@@ -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")

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()
}
}
}

View File

@@ -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<TimetableActivityAttributes> {
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<TimetableActivityAttributes>
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<TimetableActivityAttributes>
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<TimetableActivityAttributes>
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
}
}

View File

@@ -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")

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -76,161 +76,185 @@ class KretaClient {
}
static Future<void> _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<bool> _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<void> _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<void> _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<bool> 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<bool> 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<T> _mutexCallback<T>(Future<T> 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<ApiResponse<List<AllLessons>>> getLessons({bool forceCache = true}) async {
Future<ApiResponse<List<AllLessons>>> getLessons(
{bool forceCache = true}) async {
var (resp, status, ex, cached) = await _cachingGet(
CacheId.getLessons,
KretaEndpoints.getLessons(model.iss!),

View File

@@ -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<TokenGrantResponse> getAccessToken(String code) async {
@@ -48,7 +43,8 @@ Future<TokenGrantResponse> getAccessToken(String code) async {
const _tokenRefreshRetryDelays = [1000, 3000, 5000];
Future<TokenGrantResponse> 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 = <String, String>{
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
@@ -69,7 +65,8 @@ Future<TokenGrantResponse> 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<TokenGrantResponse> 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<TokenGrantResponse> 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<TokenGrantResponse> 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");
}

View File

@@ -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<T?> _invokeMethodWithTimeout<T>(
String method, [
dynamic arguments,
Duration timeout = const Duration(seconds: 10),
]) async {
try {
return await _watchChannel
.invokeMethod<T>(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<TokenModel>? 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<dynamic, dynamic> 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<String, dynamic> _buildTokenSyncPayload(
TokenModel token, {
bool includeSentAt = false,
}) {
final nowMs = DateTime.now().millisecondsSinceEpoch;
final payload = <String, dynamic>{
'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<void> _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<Map<String, dynamic>> _processTokenFromWatch(dynamic arguments) async {
/// Sends a specific token directly to Watch.
/// Useful during app initialization before global init state is fully ready.
static Future<void> sendTokenModelToWatch(TokenModel token) async {
if (!Platform.isIOS) return;
await _sendTokenToWatchInternal(token);
}
static Future<Map<String, dynamic>> _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<void> 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');

View File

@@ -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<void> _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<AppInitialization> initializeApp() async {
if (Platform.isIOS) {
try {
await LiveActivityService.initialize();
} catch (e, st) {
logger.severe('Failed to initialize LiveActivity: $e', e, st);
}

View File

@@ -162,6 +162,8 @@ class _HomeMainScreen extends FirkaState<HomeMainScreen> {
});
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<HomeMainScreen> {
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));
}
}

View File

@@ -213,14 +213,31 @@ class _HomeTimetableScreen extends FirkaState<HomeTimetableScreen>
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 {

View File

@@ -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<HomeScreen> {
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<HomeScreen> {
}
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<HomeScreen> {
});
return;
}
} catch (e) {
if (e is TokenExpiredException || e is InvalidGrantException) {
activeToast = ActiveToastType.reauth;
@@ -413,6 +391,9 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
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();
});

View File

@@ -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<LoginWebviewWidget> {
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
}