forked from firka/firka
Improve iCloud / Watch token sync & recovery
Add robust handling for token synchronization and recovery across iOS/watchOS and Flutter. - iOS (WatchSessionManager.swift): queue token events until Flutter signals watchSyncReady, flush queued auth events and iCloud recovery notifications when ready, forward tokens with de-duplication logic, provide fallback to iCloud when Flutter isn't ready, and avoid sending duplicate events. Adds helper methods for token payloads and comparison. - Token management (TokenManager.swift): track active studentIdNorm in UserDefaults to prefer a single account, persist active account on saves, prefer freshest token within preferred account, improve proactive refresh with configurable lead time and cooldown, skip unnecessary recoveries when a valid token exists, and add cooldown for phone recovery requests. - iCloud logic (iCloudTokenManager.swift): avoid overwriting tokens across accounts using updatedAt comparisons and only ignore truly stale saves. - Dart client (kreta_client.dart): skip recovery if local token still valid, clearer iCloud recovery retry flow, fallbacks, and clear reauth flag when usable token applied. - Token model (token_model.dart & generated .g.dart): add tokenVersion and updatedAtMs fields, populate them on creation/from response, and update Isar schema + generated query helpers. - Watch sync helper (watch_sync_helper.dart): include tokenVersion/updatedAt in outgoing payloads, resolve incoming/current versions more robustly, use updatedAt when deciding freshness, and invoke watchSyncReady on init so native side can flush queued events. - App init (main.dart): run an iCloud recovery check on iOS during startup and only clear reauth flag when the chosen token is still in the future. These changes improve cross-device token consistency, reduce spurious reauth prompts, and make proactive refresh/recovery less noisy and more efficient.
This commit is contained in:
@@ -6,6 +6,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
static let shared = WatchSessionManager()
|
||||
|
||||
private var flutterChannel: FlutterMethodChannel?
|
||||
private var isFlutterWatchSyncReady = false
|
||||
private var pendingAuthPayloads: [[String: Any]] = []
|
||||
private var pendingICloudRecoveryNotification = false
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
@@ -33,6 +36,8 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
self?.handleCheckiCloudToken(result: result)
|
||||
case "saveTokeToniCloud":
|
||||
self?.handleSaveTokenToiCloud(arguments: call.arguments, result: result)
|
||||
case "watchSyncReady":
|
||||
self?.handleWatchSyncReady(result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
@@ -56,7 +61,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
|
||||
@objc private func handleTokenRecoveredFromiCloud() {
|
||||
print("[WatchSessionManager] Token recovered from iCloud, notifying Flutter to clear reauth flag")
|
||||
flutterChannel?.invokeMethod("onTokenRecoveredFromiCloud", arguments: nil)
|
||||
DispatchQueue.main.async {
|
||||
self.notifyTokenRecoveredToFlutter()
|
||||
}
|
||||
}
|
||||
|
||||
private func parseInt64(_ value: Any?) -> Int64? {
|
||||
@@ -75,6 +82,90 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func tokenPayload(from token: WatchToken) -> [String: Any] {
|
||||
var tokenData: [String: Any] = [
|
||||
"studentId": token.studentId,
|
||||
"studentIdNorm": token.studentIdNorm,
|
||||
"iss": token.iss,
|
||||
"idToken": token.idToken,
|
||||
"accessToken": token.accessToken,
|
||||
"refreshToken": token.refreshToken,
|
||||
"expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000)
|
||||
]
|
||||
if let tokenVersion = token.effectiveTokenVersion {
|
||||
tokenData["tokenVersion"] = tokenVersion
|
||||
}
|
||||
if let updatedAtMs = token.effectiveUpdatedAtMs {
|
||||
tokenData["updatedAtMs"] = updatedAtMs
|
||||
}
|
||||
return tokenData
|
||||
}
|
||||
|
||||
private func fallbackTokenFromiCloud() -> [String: Any]? {
|
||||
guard let token = iCloudTokenManager.shared.loadToken() else {
|
||||
return nil
|
||||
}
|
||||
return tokenPayload(from: token)
|
||||
}
|
||||
|
||||
private func sameTokenPayload(_ lhs: [String: Any], _ rhs: [String: Any]) -> Bool {
|
||||
return parseInt64(lhs["studentIdNorm"]) == parseInt64(rhs["studentIdNorm"]) &&
|
||||
parseInt64(lhs["expiryDate"]) == parseInt64(rhs["expiryDate"]) &&
|
||||
parseInt64(lhs["tokenVersion"]) == parseInt64(rhs["tokenVersion"]) &&
|
||||
parseInt64(lhs["updatedAtMs"]) == parseInt64(rhs["updatedAtMs"]) &&
|
||||
(lhs["refreshToken"] as? String) == (rhs["refreshToken"] as? String)
|
||||
}
|
||||
|
||||
private func enqueuePendingAuth(_ authData: [String: Any]) {
|
||||
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) {
|
||||
return
|
||||
}
|
||||
pendingAuthPayloads.append(authData)
|
||||
print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready")
|
||||
}
|
||||
|
||||
private func forwardTokenToFlutter(_ authData: [String: Any]) {
|
||||
guard isFlutterWatchSyncReady else {
|
||||
enqueuePendingAuth(authData)
|
||||
return
|
||||
}
|
||||
flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
|
||||
}
|
||||
|
||||
private func notifyTokenRecoveredToFlutter() {
|
||||
guard isFlutterWatchSyncReady else {
|
||||
pendingICloudRecoveryNotification = true
|
||||
print("[WatchSessionManager] Queued iCloud recovery notification until Flutter sync is ready")
|
||||
return
|
||||
}
|
||||
flutterChannel?.invokeMethod("onTokenRecoveredFromiCloud", arguments: nil)
|
||||
}
|
||||
|
||||
private func flushPendingEvents() {
|
||||
guard isFlutterWatchSyncReady else {
|
||||
return
|
||||
}
|
||||
if !pendingAuthPayloads.isEmpty {
|
||||
print("[WatchSessionManager] Flushing \(pendingAuthPayloads.count) queued token event(s) to Flutter")
|
||||
}
|
||||
for authData in pendingAuthPayloads {
|
||||
flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
|
||||
}
|
||||
pendingAuthPayloads.removeAll()
|
||||
|
||||
if pendingICloudRecoveryNotification {
|
||||
pendingICloudRecoveryNotification = false
|
||||
flutterChannel?.invokeMethod("onTokenRecoveredFromiCloud", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWatchSyncReady(result: @escaping FlutterResult) {
|
||||
isFlutterWatchSyncReady = true
|
||||
print("[WatchSessionManager] Flutter WatchSync marked as ready")
|
||||
flushPendingEvents()
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleSendTokenToWatch(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let authData = arguments as? [String: Any] else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil))
|
||||
@@ -196,23 +287,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
print("[WatchSessionManager] Found iCloud token, expiry: \(formatter.string(from: token.expiryDate))")
|
||||
|
||||
var tokenData: [String: Any] = [
|
||||
"studentId": token.studentId,
|
||||
"studentIdNorm": token.studentIdNorm,
|
||||
"iss": token.iss,
|
||||
"idToken": token.idToken,
|
||||
"accessToken": token.accessToken,
|
||||
"refreshToken": token.refreshToken,
|
||||
"expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000)
|
||||
]
|
||||
if let tokenVersion = token.effectiveTokenVersion {
|
||||
tokenData["tokenVersion"] = tokenVersion
|
||||
}
|
||||
if let updatedAtMs = token.effectiveUpdatedAtMs {
|
||||
tokenData["updatedAtMs"] = updatedAtMs
|
||||
}
|
||||
|
||||
result(tokenData)
|
||||
result(tokenPayload(from: token))
|
||||
}
|
||||
|
||||
private func handleSaveTokenToiCloud(arguments: Any?, result: @escaping FlutterResult) {
|
||||
@@ -272,7 +347,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
let context = session.receivedApplicationContext
|
||||
if let authData = context["auth"] as? [String: Any] {
|
||||
print("[WatchSessionManager] Found pending auth in applicationContext")
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
|
||||
self.forwardTokenToFlutter(authData)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,7 +359,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
DispatchQueue.main.async {
|
||||
if let authData = applicationContext["auth"] as? [String: Any] {
|
||||
print("[WatchSessionManager] Processing auth from applicationContext")
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
|
||||
self.forwardTokenToFlutter(authData)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,19 +378,39 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
|
||||
switch action {
|
||||
case "requestToken":
|
||||
if !self.isFlutterWatchSyncReady {
|
||||
if let tokenData = self.fallbackTokenFromiCloud() {
|
||||
print("[WatchSessionManager] Flutter not ready, returning iCloud token to Watch")
|
||||
replyHandler(["auth": tokenData])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter not ready and no iCloud token available")
|
||||
replyHandler(["error": "no_token"])
|
||||
}
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in
|
||||
if let tokenData = result as? [String: Any] {
|
||||
if let error = tokenData["error"] as? String {
|
||||
print("[WatchSessionManager] Flutter returned error: \(error)")
|
||||
replyHandler(["error": error])
|
||||
if let fallbackToken = self.fallbackTokenFromiCloud() {
|
||||
print("[WatchSessionManager] Flutter returned error (\(error)), falling back to iCloud token")
|
||||
replyHandler(["auth": fallbackToken])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter returned error: \(error)")
|
||||
replyHandler(["error": error])
|
||||
}
|
||||
} else {
|
||||
print("[WatchSessionManager] Sending token to Watch")
|
||||
replyHandler(["auth": tokenData])
|
||||
}
|
||||
} else {
|
||||
print("[WatchSessionManager] No token available from Flutter")
|
||||
replyHandler(["error": "no_token"])
|
||||
if let fallbackToken = self.fallbackTokenFromiCloud() {
|
||||
print("[WatchSessionManager] No Flutter token available, falling back to iCloud token")
|
||||
replyHandler(["auth": fallbackToken])
|
||||
} else {
|
||||
print("[WatchSessionManager] No token available from Flutter")
|
||||
replyHandler(["error": "no_token"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +434,15 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
if !self.isFlutterWatchSyncReady {
|
||||
print("[WatchSessionManager] Flutter not ready, queueing token from Watch")
|
||||
DispatchQueue.main.async {
|
||||
self.enqueuePendingAuth(tokenData)
|
||||
}
|
||||
replyHandler(["success": true])
|
||||
return
|
||||
}
|
||||
|
||||
print("[WatchSessionManager] Receiving token from Watch")
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
|
||||
@@ -383,7 +487,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
|
||||
if messageId == "token_update_from_watch" {
|
||||
if let authData = userInfo["auth"] as? [String: Any] {
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
|
||||
self.forwardTokenToFlutter(authData)
|
||||
print("[WatchSessionManager] Token received from Watch")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ class TokenManager {
|
||||
private let tokenRefreshURL = "https://idp.e-kreta.hu/connect/token"
|
||||
private let clientID = "kreta-ellenorzo-student-mobile-ios"
|
||||
private let userAgent = "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0"
|
||||
private let activeStudentIdNormKey = "firka.active_student_id_norm"
|
||||
private let proactiveRefreshLeadTime: TimeInterval = 5 * 60
|
||||
private let minimumProactiveRefreshInterval: TimeInterval = 60
|
||||
|
||||
#if os(iOS)
|
||||
private let deviceName = "iPhone"
|
||||
@@ -48,6 +51,10 @@ class TokenManager {
|
||||
#endif
|
||||
private let recoveryLock = NSLock()
|
||||
private var recoveryInProgress = false
|
||||
private var lastProactiveRefreshAttemptAt: Date?
|
||||
#if os(watchOS)
|
||||
private var lastPhoneRecoveryRequestAt: Date?
|
||||
#endif
|
||||
|
||||
private func startRecoveryIfNeeded() -> Bool {
|
||||
recoveryLock.lock()
|
||||
@@ -71,26 +78,62 @@ class TokenManager {
|
||||
return recoveryInProgress
|
||||
}
|
||||
|
||||
private func getActiveStudentIdNorm() -> Int64? {
|
||||
if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Int64 {
|
||||
return value
|
||||
}
|
||||
if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Int {
|
||||
return Int64(value)
|
||||
}
|
||||
if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Double {
|
||||
return Int64(value)
|
||||
}
|
||||
if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? String,
|
||||
let parsed = Int64(value) {
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func setActiveStudentIdNorm(_ studentIdNorm: Int64) {
|
||||
UserDefaults.standard.set(studentIdNorm, forKey: activeStudentIdNormKey)
|
||||
}
|
||||
|
||||
private init() {
|
||||
iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in
|
||||
guard let self = self else { return }
|
||||
|
||||
let preferredStudentIdNorm = self.getActiveStudentIdNorm()
|
||||
if let preferredStudentIdNorm,
|
||||
iCloudToken.studentIdNorm != preferredStudentIdNorm {
|
||||
print("[TokenManager] Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
|
||||
return
|
||||
}
|
||||
|
||||
let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60)
|
||||
|
||||
let keychainToken = self.loadTokenFromKeychain()
|
||||
let fileToken = self.loadTokenFromFile()
|
||||
let localToken: WatchToken? = {
|
||||
if let k = keychainToken, let f = fileToken {
|
||||
return k.isNewer(than: f) ? k : f
|
||||
var localCandidates: [WatchToken] = []
|
||||
if let keychainToken { localCandidates.append(keychainToken) }
|
||||
if let fileToken { localCandidates.append(fileToken) }
|
||||
if let preferredStudentIdNorm {
|
||||
let filtered = localCandidates.filter { $0.studentIdNorm == preferredStudentIdNorm }
|
||||
if !filtered.isEmpty {
|
||||
localCandidates = filtered
|
||||
}
|
||||
return keychainToken ?? fileToken
|
||||
}()
|
||||
}
|
||||
let localToken = localCandidates.dropFirst().reduce(localCandidates.first) { best, candidate in
|
||||
guard let best else { return candidate }
|
||||
return candidate.isNewer(than: best) ? candidate : best
|
||||
}
|
||||
|
||||
if let localToken = localToken {
|
||||
if iCloudToken.isNewer(than: localToken) {
|
||||
print("[TokenManager] iCloud token is fresher, updating local cache")
|
||||
try? self.saveTokenToKeychain(iCloudToken)
|
||||
try? self.saveTokenToFile(iCloudToken)
|
||||
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
|
||||
|
||||
#if os(watchOS)
|
||||
DataStore.shared.checkTokenState()
|
||||
@@ -108,6 +151,7 @@ class TokenManager {
|
||||
print("[TokenManager] No local token, using iCloud token")
|
||||
try? self.saveTokenToKeychain(iCloudToken)
|
||||
try? self.saveTokenToFile(iCloudToken)
|
||||
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
|
||||
|
||||
#if os(watchOS)
|
||||
DataStore.shared.checkTokenState()
|
||||
@@ -158,22 +202,39 @@ class TokenManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
let freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in
|
||||
let preferredStudentIdNorm = getActiveStudentIdNorm()
|
||||
let preferredCandidates: [(token: WatchToken, source: String)]
|
||||
if let preferredStudentIdNorm {
|
||||
let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm }
|
||||
preferredCandidates = filtered.isEmpty ? candidates : filtered
|
||||
if filtered.isEmpty {
|
||||
print("[TokenManager] Active account token not found locally, falling back to freshest available account")
|
||||
}
|
||||
} else {
|
||||
preferredCandidates = candidates
|
||||
}
|
||||
|
||||
let freshest = preferredCandidates.dropFirst().reduce(preferredCandidates[0]) { currentBest, candidate in
|
||||
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
|
||||
}
|
||||
setActiveStudentIdNorm(freshest.token.studentIdNorm)
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
|
||||
print("[TokenManager] Token sources found: \(candidates.map { "\($0.source): \(formatter.string(from: $0.token.expiryDate))" }.joined(separator: ", "))")
|
||||
print("[TokenManager] Token sources found: \(candidates.map { "\($0.source): \($0.token.studentIdNorm) @ \(formatter.string(from: $0.token.expiryDate))" }.joined(separator: ", "))")
|
||||
print("[TokenManager] Using freshest token from \(freshest.source) (expiry: \(formatter.string(from: freshest.token.expiryDate)))")
|
||||
|
||||
if keychainToken == nil || freshest.token.isNewer(than: keychainToken!) {
|
||||
if keychainToken == nil ||
|
||||
keychainToken!.studentIdNorm != freshest.token.studentIdNorm ||
|
||||
freshest.token.isNewer(than: keychainToken!) {
|
||||
print("[TokenManager] Syncing fresher token to keychain")
|
||||
try? saveTokenToKeychain(freshest.token)
|
||||
}
|
||||
if fileToken == nil || freshest.token.isNewer(than: fileToken!) {
|
||||
if fileToken == nil ||
|
||||
fileToken!.studentIdNorm != freshest.token.studentIdNorm ||
|
||||
freshest.token.isNewer(than: fileToken!) {
|
||||
print("[TokenManager] Syncing fresher token to file")
|
||||
try? saveTokenToFile(freshest.token)
|
||||
}
|
||||
@@ -201,6 +262,7 @@ class TokenManager {
|
||||
print("[TokenManager] Deleting token from all storage locations")
|
||||
deleteTokenFromKeychain()
|
||||
iCloudTokenManager.shared.deleteToken()
|
||||
UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey)
|
||||
|
||||
guard let filePath = getTokenFilePath() else { return }
|
||||
try? FileManager.default.removeItem(at: filePath)
|
||||
@@ -214,6 +276,7 @@ class TokenManager {
|
||||
}
|
||||
|
||||
print("[TokenManager] Saving token locally (Keychain + file)")
|
||||
setActiveStudentIdNorm(token.studentIdNorm)
|
||||
|
||||
try saveTokenToKeychain(token)
|
||||
|
||||
@@ -327,24 +390,35 @@ class TokenManager {
|
||||
return false
|
||||
}
|
||||
|
||||
let proactiveThreshold = token.expiryDate.addingTimeInterval(-12 * 3600)
|
||||
let proactiveThreshold = token.expiryDate.addingTimeInterval(-proactiveRefreshLeadTime)
|
||||
return Date() >= proactiveThreshold
|
||||
}
|
||||
|
||||
func refreshTokenProactively() async {
|
||||
guard let token = loadToken() else {
|
||||
guard loadToken() != nil else {
|
||||
print("[TokenManager] No token available for proactive refresh")
|
||||
return
|
||||
}
|
||||
|
||||
let proactiveThreshold = token.expiryDate.addingTimeInterval(-12 * 3600)
|
||||
guard Date() >= proactiveThreshold else {
|
||||
print("[TokenManager] Token still valid, no proactive refresh needed")
|
||||
guard shouldRefreshProactively() else {
|
||||
print("[TokenManager] Token is not close to expiry, no proactive refresh needed")
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
if let lastAttempt = lastProactiveRefreshAttemptAt,
|
||||
now.timeIntervalSince(lastAttempt) < minimumProactiveRefreshInterval {
|
||||
print("[TokenManager] Proactive refresh skipped due to cooldown")
|
||||
return
|
||||
}
|
||||
lastProactiveRefreshAttemptAt = now
|
||||
|
||||
print("[TokenManager] Proactively refreshing token...")
|
||||
do {
|
||||
guard let token = loadToken() else {
|
||||
print("[TokenManager] Token disappeared before proactive refresh")
|
||||
return
|
||||
}
|
||||
_ = try await refreshTokenInternal(token)
|
||||
print("[TokenManager] Proactive token refresh succeeded")
|
||||
} catch {
|
||||
@@ -354,6 +428,11 @@ class TokenManager {
|
||||
|
||||
// MARK: - Central Token Recovery
|
||||
func recoverToken() async -> WatchToken? {
|
||||
if let validToken = loadToken(), !isTokenExpired() {
|
||||
print("[TokenManager] Existing token is valid, skipping recovery flow")
|
||||
return validToken
|
||||
}
|
||||
|
||||
if !startRecoveryIfNeeded() {
|
||||
print("[TokenManager] Recovery already in progress, waiting for current recovery...")
|
||||
for _ in 0..<40 {
|
||||
@@ -383,6 +462,10 @@ class TokenManager {
|
||||
|
||||
print("[TokenManager] Step 1: Trying local token refresh...")
|
||||
if let token = loadToken() {
|
||||
if token.expiryDate > Date().addingTimeInterval(60) {
|
||||
print("[TokenManager] Step 1 SUCCESS: Local token already valid")
|
||||
return token
|
||||
}
|
||||
do {
|
||||
let refreshedToken = try await refreshTokenInternal(token)
|
||||
print("[TokenManager] Step 1 SUCCESS: Local refresh succeeded")
|
||||
@@ -396,12 +479,18 @@ class TokenManager {
|
||||
|
||||
print("[TokenManager] Step 2: Checking Keychain and WatchConnectivity...")
|
||||
if let recoveredToken = await tryRecoverFromKeychainAndWatch() {
|
||||
do {
|
||||
let refreshedToken = try await refreshTokenInternal(recoveredToken)
|
||||
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token refresh succeeded")
|
||||
return refreshedToken
|
||||
} catch {
|
||||
print("[TokenManager] Step 2 FAILED: Keychain/Watch token refresh failed: \(error)")
|
||||
if recoveredToken.expiryDate > Date().addingTimeInterval(60) {
|
||||
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token is already valid")
|
||||
try? saveToken(recoveredToken, syncToICloud: false)
|
||||
return recoveredToken
|
||||
} else {
|
||||
do {
|
||||
let refreshedToken = try await refreshTokenInternal(recoveredToken)
|
||||
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token refresh succeeded")
|
||||
return refreshedToken
|
||||
} catch {
|
||||
print("[TokenManager] Step 2 FAILED: Keychain/Watch token refresh failed: \(error)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] Step 2 SKIPPED: No token from Keychain/Watch")
|
||||
@@ -424,16 +513,16 @@ class TokenManager {
|
||||
print("[TokenManager] Step 3: iCloud attempt \(attempt + 1)/\(retryDelays.count)...")
|
||||
|
||||
if let iCloudToken = iCloudTokenManager.shared.loadToken() {
|
||||
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
|
||||
iCloudToken.studentIdNorm != preferredStudentIdNorm {
|
||||
print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
|
||||
continue
|
||||
}
|
||||
iCloudHasToken = true
|
||||
if iCloudToken.expiryDate > Date() {
|
||||
print("[TokenManager] Step 3: Found valid iCloud token, trying refresh...")
|
||||
do {
|
||||
let refreshedToken = try await refreshTokenInternal(iCloudToken)
|
||||
print("[TokenManager] Step 3 SUCCESS: iCloud token refresh succeeded on attempt \(attempt + 1)")
|
||||
return refreshedToken
|
||||
} catch {
|
||||
print("[TokenManager] Step 3: iCloud token refresh failed on attempt \(attempt + 1): \(error)")
|
||||
}
|
||||
print("[TokenManager] Step 3 SUCCESS: Found valid iCloud token, applying without immediate refresh")
|
||||
try? saveToken(iCloudToken, syncToICloud: false)
|
||||
return iCloudToken
|
||||
} else {
|
||||
print("[TokenManager] Step 3: iCloud token is expired, trying refresh anyway...")
|
||||
do {
|
||||
@@ -480,6 +569,16 @@ class TokenManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let preferredStudentIdNorm = getActiveStudentIdNorm() {
|
||||
let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm }
|
||||
if !filtered.isEmpty {
|
||||
candidates = filtered
|
||||
} else {
|
||||
print("[TokenManager] No recovery candidate for active account \(preferredStudentIdNorm)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in
|
||||
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
|
||||
}
|
||||
@@ -495,6 +594,14 @@ class TokenManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
if let lastPhoneRecoveryRequestAt,
|
||||
now.timeIntervalSince(lastPhoneRecoveryRequestAt) < 5 {
|
||||
print("[TokenManager] Skipping iPhone recovery request due to cooldown")
|
||||
return nil
|
||||
}
|
||||
lastPhoneRecoveryRequestAt = now
|
||||
|
||||
let timeoutSeconds: UInt64 = 10
|
||||
|
||||
return await withTaskGroup(of: WatchToken?.self) { group in
|
||||
|
||||
@@ -41,14 +41,25 @@ class iCloudTokenManager {
|
||||
return
|
||||
}
|
||||
|
||||
if let existingToken = loadToken(),
|
||||
existingToken.isSameAccount(as: token),
|
||||
!token.isNewer(than: existingToken) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[iCloud] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
|
||||
return
|
||||
if let existingToken = loadToken() {
|
||||
if existingToken.isSameAccount(as: token) {
|
||||
if !token.isNewer(than: existingToken) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[iCloud] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0
|
||||
let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0
|
||||
if incomingUpdatedAt > 0 &&
|
||||
existingUpdatedAt > 0 &&
|
||||
incomingUpdatedAt <= existingUpdatedAt {
|
||||
print("[iCloud] Ignoring cross-account stale token save from \(deviceName)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("[iCloud] Saving token to iCloud from \(deviceName)")
|
||||
|
||||
@@ -136,6 +136,15 @@ class KretaClient {
|
||||
|
||||
Future<bool> recoverToken() async {
|
||||
logger.info("[Recovery] Starting central token recovery...");
|
||||
final now = timeNow();
|
||||
final localExpiry = model.expiryDate;
|
||||
if (localExpiry != null &&
|
||||
localExpiry.isAfter(now.add(const Duration(seconds: 60)))) {
|
||||
logger.info(
|
||||
"[Recovery] Existing token is still valid, skipping recovery steps");
|
||||
clearReauthFlag();
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info("[Recovery] Step 1: Trying local token refresh...");
|
||||
try {
|
||||
@@ -148,6 +157,7 @@ class KretaClient {
|
||||
|
||||
model = tokenModel;
|
||||
await _syncTokenToAppleTargets(model);
|
||||
clearReauthFlag();
|
||||
logger.info("[Recovery] Step 1 SUCCESS: Local refresh succeeded");
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -155,13 +165,15 @@ class KretaClient {
|
||||
}
|
||||
|
||||
if (!Platform.isIOS || !initDone) {
|
||||
logger.warning("[Recovery] Not iOS or not initialized, cannot try iCloud");
|
||||
logger
|
||||
.warning("[Recovery] Not iOS or not initialized, cannot try iCloud");
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info("[Recovery] Step 2: Trying iCloud recovery with retries...");
|
||||
const retryDelays = [0, 5, 10, 5, 10]; // instant, 5s, 10s, 5s, 10s
|
||||
bool iCloudHasToken = false; // Track if iCloud has any token (to avoid useless retries)
|
||||
bool iCloudHasToken =
|
||||
false; // Track if iCloud has any token (to avoid useless retries)
|
||||
|
||||
for (var attempt = 0; attempt < retryDelays.length; attempt++) {
|
||||
final delay = retryDelays[attempt];
|
||||
@@ -170,11 +182,13 @@ class KretaClient {
|
||||
logger.info("[Recovery] Skipping retries - iCloud has no token");
|
||||
break;
|
||||
}
|
||||
logger.info("[Recovery] Waiting ${delay}s before attempt ${attempt + 1}...");
|
||||
logger.info(
|
||||
"[Recovery] Waiting ${delay}s before attempt ${attempt + 1}...");
|
||||
await Future.delayed(Duration(seconds: delay));
|
||||
}
|
||||
|
||||
logger.info("[Recovery] iCloud attempt ${attempt + 1}/${retryDelays.length}...");
|
||||
logger.info(
|
||||
"[Recovery] iCloud attempt ${attempt + 1}/${retryDelays.length}...");
|
||||
|
||||
final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud(
|
||||
isar: isar,
|
||||
@@ -184,9 +198,21 @@ class KretaClient {
|
||||
|
||||
if (recovered) {
|
||||
iCloudHasToken = true;
|
||||
await _reloadActiveTokenModel(preferredStudentIdNorm: model.studentIdNorm);
|
||||
await _reloadActiveTokenModel(
|
||||
preferredStudentIdNorm: model.studentIdNorm);
|
||||
|
||||
logger.info("[Recovery] Found iCloud token, trying refresh...");
|
||||
final recoveredExpiry = model.expiryDate;
|
||||
if (recoveredExpiry != null &&
|
||||
recoveredExpiry
|
||||
.isAfter(timeNow().add(const Duration(seconds: 60)))) {
|
||||
logger.info(
|
||||
"[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}: usable iCloud token applied without immediate refresh");
|
||||
clearReauthFlag();
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[Recovery] Found iCloud token close to expiry, trying refresh...");
|
||||
try {
|
||||
var extended = await extendToken(model);
|
||||
var tokenModel = TokenModel.fromResp(extended);
|
||||
@@ -197,14 +223,17 @@ class KretaClient {
|
||||
|
||||
model = tokenModel;
|
||||
await _syncTokenToAppleTargets(model);
|
||||
clearReauthFlag();
|
||||
logger.info("[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}");
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warning("[Recovery] iCloud token refresh failed on attempt ${attempt + 1}: $e");
|
||||
logger.warning(
|
||||
"[Recovery] iCloud token refresh failed on attempt ${attempt + 1}: $e");
|
||||
iCloudHasToken = true;
|
||||
}
|
||||
} else {
|
||||
logger.info("[Recovery] No fresh token in iCloud on attempt ${attempt + 1}");
|
||||
logger.info(
|
||||
"[Recovery] No fresh token in iCloud on attempt ${attempt + 1}");
|
||||
if (attempt == 0) {
|
||||
iCloudHasToken = false;
|
||||
}
|
||||
@@ -221,7 +250,8 @@ class KretaClient {
|
||||
|
||||
if (model.expiryDate == null ||
|
||||
model.expiryDate!.isBefore(fiveMinutesFromNow)) {
|
||||
logger.info("[Proactive] Token expired or expiring soon, starting recovery...");
|
||||
logger.info(
|
||||
"[Proactive] Token expired or expiring soon, starting recovery...");
|
||||
|
||||
final recovered = await recoverToken();
|
||||
if (recovered) {
|
||||
@@ -251,7 +281,8 @@ class KretaClient {
|
||||
|
||||
while (_tokenMutex) {
|
||||
if (DateTime.now().difference(startTime) > maxWaitTime) {
|
||||
logger.warning("[Mutex] Timeout waiting for token mutex, forcing release");
|
||||
logger.warning(
|
||||
"[Mutex] Timeout waiting for token mutex, forcing release");
|
||||
_tokenMutex = false;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,22 @@ class TokenModel {
|
||||
String? accessToken; // The main auth token
|
||||
String? refreshToken; // Token used to refresh the access token
|
||||
DateTime? expiryDate;
|
||||
int? tokenVersion;
|
||||
int? updatedAtMs;
|
||||
|
||||
TokenModel();
|
||||
|
||||
factory TokenModel.fromValues(Id studentIdNorm, studentId, String iss,
|
||||
String idToken, String accessToken, String refreshToken, int expiryDate) {
|
||||
factory TokenModel.fromValues(
|
||||
Id studentIdNorm,
|
||||
studentId,
|
||||
String iss,
|
||||
String idToken,
|
||||
String accessToken,
|
||||
String refreshToken,
|
||||
int expiryDate, {
|
||||
int? tokenVersion,
|
||||
int? updatedAtMs,
|
||||
}) {
|
||||
var m = TokenModel();
|
||||
|
||||
m.studentIdNorm = studentIdNorm;
|
||||
@@ -33,6 +44,8 @@ class TokenModel {
|
||||
m.accessToken = accessToken;
|
||||
m.refreshToken = refreshToken;
|
||||
m.expiryDate = DateTime.fromMillisecondsSinceEpoch(expiryDate);
|
||||
m.tokenVersion = tokenVersion;
|
||||
m.updatedAtMs = updatedAtMs;
|
||||
|
||||
return m;
|
||||
}
|
||||
@@ -67,6 +80,16 @@ class TokenModel {
|
||||
m.expiryDate = timeNow()
|
||||
.add(Duration(seconds: resp.expiresIn))
|
||||
.subtract(Duration(minutes: 1)); // just to be safe
|
||||
final iat = payload["iat"];
|
||||
if (iat is int) {
|
||||
m.tokenVersion = iat * 1000;
|
||||
} else if (iat is String) {
|
||||
final parsed = int.tryParse(iat);
|
||||
if (parsed != null) {
|
||||
m.tokenVersion = parsed * 1000;
|
||||
}
|
||||
}
|
||||
m.updatedAtMs = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ const TokenModelSchema = CollectionSchema(
|
||||
id: 5,
|
||||
name: r'studentId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'tokenVersion': PropertySchema(
|
||||
id: 6,
|
||||
name: r'tokenVersion',
|
||||
type: IsarType.long,
|
||||
),
|
||||
r'updatedAtMs': PropertySchema(
|
||||
id: 7,
|
||||
name: r'updatedAtMs',
|
||||
type: IsarType.long,
|
||||
)
|
||||
},
|
||||
estimateSize: _tokenModelEstimateSize,
|
||||
@@ -113,6 +123,8 @@ void _tokenModelSerialize(
|
||||
writer.writeString(offsets[3], object.iss);
|
||||
writer.writeString(offsets[4], object.refreshToken);
|
||||
writer.writeString(offsets[5], object.studentId);
|
||||
writer.writeLong(offsets[6], object.tokenVersion);
|
||||
writer.writeLong(offsets[7], object.updatedAtMs);
|
||||
}
|
||||
|
||||
TokenModel _tokenModelDeserialize(
|
||||
@@ -129,6 +141,8 @@ TokenModel _tokenModelDeserialize(
|
||||
object.refreshToken = reader.readStringOrNull(offsets[4]);
|
||||
object.studentId = reader.readStringOrNull(offsets[5]);
|
||||
object.studentIdNorm = id;
|
||||
object.tokenVersion = reader.readLongOrNull(offsets[6]);
|
||||
object.updatedAtMs = reader.readLongOrNull(offsets[7]);
|
||||
return object;
|
||||
}
|
||||
|
||||
@@ -151,6 +165,10 @@ P _tokenModelDeserializeProp<P>(
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 5:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 6:
|
||||
return (reader.readLongOrNull(offset)) as P;
|
||||
case 7:
|
||||
return (reader.readLongOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
@@ -1153,6 +1171,154 @@ extension TokenModelQueryFilter
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
tokenVersionIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'tokenVersion',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
tokenVersionIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'tokenVersion',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
tokenVersionEqualTo(int? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'tokenVersion',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
tokenVersionGreaterThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'tokenVersion',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
tokenVersionLessThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'tokenVersion',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
tokenVersionBetween(
|
||||
int? lower,
|
||||
int? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'tokenVersion',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
updatedAtMsIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'updatedAtMs',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
updatedAtMsIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'updatedAtMs',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
updatedAtMsEqualTo(int? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'updatedAtMs',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
updatedAtMsGreaterThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'updatedAtMs',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
updatedAtMsLessThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'updatedAtMs',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterFilterCondition>
|
||||
updatedAtMsBetween(
|
||||
int? lower,
|
||||
int? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'updatedAtMs',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension TokenModelQueryObject
|
||||
@@ -1234,6 +1400,30 @@ extension TokenModelQuerySortBy
|
||||
return query.addSortBy(r'studentId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> sortByTokenVersion() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'tokenVersion', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> sortByTokenVersionDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'tokenVersion', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> sortByUpdatedAtMs() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAtMs', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> sortByUpdatedAtMsDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAtMs', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension TokenModelQuerySortThenBy
|
||||
@@ -1321,6 +1511,30 @@ extension TokenModelQuerySortThenBy
|
||||
return query.addSortBy(r'studentIdNorm', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> thenByTokenVersion() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'tokenVersion', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> thenByTokenVersionDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'tokenVersion', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> thenByUpdatedAtMs() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAtMs', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QAfterSortBy> thenByUpdatedAtMsDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAtMs', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension TokenModelQueryWhereDistinct
|
||||
@@ -1365,6 +1579,18 @@ extension TokenModelQueryWhereDistinct
|
||||
return query.addDistinctBy(r'studentId', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QDistinct> distinctByTokenVersion() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'tokenVersion');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, TokenModel, QDistinct> distinctByUpdatedAtMs() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'updatedAtMs');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension TokenModelQueryProperty
|
||||
@@ -1410,4 +1636,16 @@ extension TokenModelQueryProperty
|
||||
return query.addPropertyName(r'studentId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, int?, QQueryOperations> tokenVersionProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'tokenVersion');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<TokenModel, int?, QQueryOperations> updatedAtMsProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'updatedAtMs');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -96,16 +97,27 @@ class WatchSyncHelper {
|
||||
}
|
||||
}
|
||||
|
||||
static int _resolveTokenVersionForSend(TokenModel token) {
|
||||
return _extractTokenVersionFromIdToken(token.idToken) ??
|
||||
DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
static int? _resolveIncomingTokenVersion(Map<dynamic, dynamic> tokenData) {
|
||||
return _asInt(tokenData['tokenVersion']) ??
|
||||
_extractTokenVersionFromIdToken(tokenData['idToken'] as String?);
|
||||
}
|
||||
|
||||
static int? _resolveTokenVersionForModel(TokenModel token) {
|
||||
final tokenVersion = token.tokenVersion;
|
||||
if (tokenVersion != null && tokenVersion > 0) {
|
||||
return tokenVersion;
|
||||
}
|
||||
return _extractTokenVersionFromIdToken(token.idToken);
|
||||
}
|
||||
|
||||
static int? _resolveUpdatedAtForModel(TokenModel token) {
|
||||
final updatedAtMs = token.updatedAtMs;
|
||||
if (updatedAtMs != null && updatedAtMs > 0) {
|
||||
return updatedAtMs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isIncomingTokenNewerThanCurrent({
|
||||
required DateTime incomingExpiry,
|
||||
required String? incomingIdToken,
|
||||
@@ -119,9 +131,10 @@ class WatchSyncHelper {
|
||||
return true;
|
||||
}
|
||||
|
||||
final incomingVersion =
|
||||
incomingTokenVersion ?? _extractTokenVersionFromIdToken(incomingIdToken);
|
||||
final currentVersion = _extractTokenVersionFromIdToken(currentToken.idToken);
|
||||
final incomingVersion = incomingTokenVersion ??
|
||||
_extractTokenVersionFromIdToken(incomingIdToken);
|
||||
final currentVersion = _resolveTokenVersionForModel(currentToken);
|
||||
final currentUpdatedAtMs = _resolveUpdatedAtForModel(currentToken);
|
||||
|
||||
if (incomingVersion != null &&
|
||||
currentVersion != null &&
|
||||
@@ -143,14 +156,26 @@ class WatchSyncHelper {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (incomingUpdatedAtMs != null &&
|
||||
currentUpdatedAtMs != null &&
|
||||
incomingUpdatedAtMs != currentUpdatedAtMs) {
|
||||
return incomingUpdatedAtMs > currentUpdatedAtMs;
|
||||
}
|
||||
if (incomingUpdatedAtMs != null && currentUpdatedAtMs == null) {
|
||||
return true;
|
||||
}
|
||||
if (incomingUpdatedAtMs == null && currentUpdatedAtMs != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final currentRefresh = currentToken.refreshToken;
|
||||
if (incomingRefreshToken != null &&
|
||||
currentRefresh != null &&
|
||||
incomingRefreshToken != currentRefresh) {
|
||||
if (incomingUpdatedAtMs != null && incomingUpdatedAtMs > 0) {
|
||||
if (incomingIdToken != null && incomingIdToken != currentToken.idToken) {
|
||||
return true;
|
||||
}
|
||||
return incomingIdToken != null && incomingIdToken != currentToken.idToken;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -161,6 +186,10 @@ class WatchSyncHelper {
|
||||
bool includeSentAt = false,
|
||||
}) {
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final tokenVersion = _resolveTokenVersionForModel(token) ?? nowMs;
|
||||
final updatedAtMs = (token.updatedAtMs != null && token.updatedAtMs! > 0)
|
||||
? token.updatedAtMs!
|
||||
: nowMs;
|
||||
final payload = <String, dynamic>{
|
||||
'studentId': token.studentId,
|
||||
'studentIdNorm': token.studentIdNorm,
|
||||
@@ -169,8 +198,8 @@ class WatchSyncHelper {
|
||||
'accessToken': token.accessToken,
|
||||
'refreshToken': token.refreshToken,
|
||||
'expiryDate': token.expiryDate!.millisecondsSinceEpoch,
|
||||
'tokenVersion': _resolveTokenVersionForSend(token),
|
||||
'updatedAtMs': nowMs,
|
||||
'tokenVersion': tokenVersion,
|
||||
'updatedAtMs': updatedAtMs,
|
||||
};
|
||||
if (includeSentAt) {
|
||||
payload['sentAtMs'] = nowMs;
|
||||
@@ -185,6 +214,11 @@ class WatchSyncHelper {
|
||||
|
||||
_watchChannel.setMethodCallHandler(_handleMethodCall);
|
||||
debugPrint('[WatchSync] Handler initialized');
|
||||
unawaited(_invokeMethodWithTimeout(
|
||||
'watchSyncReady',
|
||||
null,
|
||||
const Duration(seconds: 2),
|
||||
));
|
||||
}
|
||||
|
||||
static Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||
@@ -362,6 +396,8 @@ class WatchSyncHelper {
|
||||
tokenData['accessToken'] as String,
|
||||
tokenData['refreshToken'] as String,
|
||||
watchExpiry,
|
||||
tokenVersion: watchTokenVersion,
|
||||
updatedAtMs: watchUpdatedAtMs ?? DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
await initData.isar.writeTxn(() async {
|
||||
@@ -512,6 +548,9 @@ class WatchSyncHelper {
|
||||
tokenData['accessToken'] as String,
|
||||
tokenData['refreshToken'] as String,
|
||||
iCloudExpiry,
|
||||
tokenVersion: iCloudTokenVersion,
|
||||
updatedAtMs:
|
||||
iCloudUpdatedAtMs ?? DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
await effectiveIsar.writeTxn(() async {
|
||||
@@ -675,6 +714,9 @@ class WatchSyncHelper {
|
||||
tokenData['accessToken'] as String,
|
||||
tokenData['refreshToken'] as String,
|
||||
watchExpiry,
|
||||
tokenVersion: watchTokenVersion,
|
||||
updatedAtMs:
|
||||
watchUpdatedAtMs ?? DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
await effectiveIsar.writeTxn(() async {
|
||||
|
||||
@@ -207,9 +207,21 @@ Future<void> _initData(AppInitialization init) async {
|
||||
resetOldTimeTableCache(init.isar);
|
||||
resetOldHomeworkCache(init.isar);
|
||||
|
||||
if (init.tokens.isNotEmpty) {
|
||||
final allTokens = await init.isar.tokenModels.where().findAll();
|
||||
init.tokens = allTokens;
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await WatchSyncHelper.checkAndRecoverFromiCloud(
|
||||
isar: init.isar,
|
||||
tokens: init.tokens,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warning('[Init] iCloud recovery check failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final allTokens = await init.isar.tokenModels.where().findAll();
|
||||
init.tokens = allTokens;
|
||||
|
||||
if (allTokens.isNotEmpty) {
|
||||
final token = pickActiveToken(tokens: allTokens, settings: init.settings);
|
||||
if (token == null) {
|
||||
logger.warning(
|
||||
@@ -221,7 +233,7 @@ Future<void> _initData(AppInitialization init) async {
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final expiryDate = token.expiryDate;
|
||||
if (expiryDate != null) {
|
||||
if (expiryDate != null && expiryDate.isAfter(DateTime.now())) {
|
||||
KretaClient.clearReauthFlag();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user