forked from firka/firka
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:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user