1
0
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:
Horváth Gergely
2026-02-11 11:33:11 +01:00
committed by 4831c0
parent 0b78712e64
commit 55de7d1645
8 changed files with 657 additions and 89 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');
});
}
}

View File

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

View File

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