diff --git a/firka/ios/FirkaWatch Watch App/ContentView.swift b/firka/ios/FirkaWatch Watch App/ContentView.swift index 6278fcb..36810e5 100644 --- a/firka/ios/FirkaWatch Watch App/ContentView.swift +++ b/firka/ios/FirkaWatch Watch App/ContentView.swift @@ -30,7 +30,7 @@ struct ContentView: View { dataStore.resetRecoveryState() dataStore.checkTokenState() Task { - await dataStore.refreshAll() + await dataStore.refreshAllWithRecovery() } }) } else if !dataStore.hasToken && dataStore.data == nil { @@ -47,14 +47,7 @@ struct ContentView: View { dataStore.checkTokenState() dataStore.loadFromCache() if dataStore.hasToken { - await dataStore.refreshAll() - - if (dataStore.error == "token_expired" || dataStore.error == "no_token") && !dataStore.recoveryAttempted { - let recovered = await dataStore.attemptTokenRecovery() - if recovered { - await dataStore.refreshAll() - } - } + await dataStore.refreshAllWithRecovery() } else { requestToken() } @@ -64,7 +57,7 @@ struct ContentView: View { if shouldAutoRefresh { print("[Watch] App came to foreground, data is stale (>10 min), refreshing...") Task { - await dataStore.refreshAll() + await dataStore.refreshAllWithRecovery() } } else { print("[Watch] App came to foreground, data is fresh (<10 min), skipping refresh") @@ -75,7 +68,7 @@ struct ContentView: View { if scenePhase == .active && shouldAutoRefresh && !dataStore.isLoading { print("[Watch] Data became stale (>10 min), auto-refreshing...") Task { - await dataStore.refreshAll() + await dataStore.refreshAllWithRecovery() } } } diff --git a/firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift b/firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift index e1dc322..451e85e 100644 --- a/firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift +++ b/firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift @@ -111,7 +111,7 @@ class BackgroundRefreshManager { } func handleBackgroundRefresh() async { - await DataStore.shared.refreshAll() + await DataStore.shared.refreshAllWithRecovery() WidgetCenter.shared.reloadAllTimelines() diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index 4a659b5..22e58c0 100644 --- a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -154,6 +154,7 @@ class DataStore { } isRecoveringToken = true + recoveryAttempted = false error = nil print("[Watch] Starting background token recovery...") @@ -161,47 +162,59 @@ class DataStore { isRecoveringToken = false } - print("[Watch] Recovery 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 Step 2: Attempting API token refresh...") + let retryDelays: [UInt64] = [0, 2, 5] var isNetworkError = false var isTokenPermanentlyInvalid = false - 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 + 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) } - } catch { - print("[Watch] Recovery: API token refresh failed with unknown error: \(error)") - isNetworkError = true // Assume network issue for unknown errors - } - print("[Watch] Recovery Step 3: Checking if iPhone is reachable...") - if await requestTokenFromiPhoneAsync() { - print("[Watch] Recovery: Got token from iPhone!") - checkTokenState() - return true + 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 isTokenPermanentlyInvalid { @@ -212,9 +225,8 @@ class DataStore { print("[Watch] Recovery: Network error - not showing reauth, user can retry") self.error = "network" } else { - print("[Watch] Recovery: Unknown failure, showing reauth screen") - recoveryAttempted = true - self.error = "token_expired" + print("[Watch] Recovery: All retries failed, treating as transient/network issue") + self.error = "network" } return false @@ -351,6 +363,20 @@ class DataStore { } } + func refreshAllWithRecovery() async { + await refreshAll() + + guard error == "token_expired" || error == "no_token" else { + return + } + + print("[Watch] Token issue after refreshAll(), starting auto-recovery flow...") + let recovered = await attemptTokenRecovery() + if recovered { + await refreshAll() + } + } + /// Handles API errors and maps them to user-friendly messages private func handleAPIError(_ error: APIError) { print("[Watch] handleAPIError: \(error)") diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift index bdb2bd7..2efccef 100644 --- a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift +++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift @@ -289,7 +289,7 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { DataStore.shared.checkTokenState() Task { - await DataStore.shared.refreshAll() + await DataStore.shared.refreshAllWithRecovery() print("[Watch] Data refresh completed") } } catch { diff --git a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift index c0241fc..94fa3bb 100644 --- a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift @@ -54,7 +54,7 @@ struct HomeView: View { guard !dataStore.isLoading else { return } Task { refreshStatus = .loading - await dataStore.refreshAll() + await dataStore.refreshAllWithRecovery() if dataStore.error == nil && dataStore.data != nil { refreshStatus = .success } else { diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift index a39307e..8197565 100644 --- a/firka/ios/Shared/API/TokenManager.swift +++ b/firka/ios/Shared/API/TokenManager.swift @@ -50,7 +50,16 @@ class TokenManager { let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60) - if let localToken = self.loadTokenFromKeychain() { + let keychainToken = self.loadTokenFromKeychain() + let fileToken = self.loadTokenFromFile() + let localToken: WatchToken? = { + if let k = keychainToken, let f = fileToken { + return k.expiryDate > f.expiryDate ? 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") try? self.saveTokenToKeychain(iCloudToken) @@ -66,7 +75,7 @@ class TokenManager { } #endif } else { - print("[TokenManager] Local token is fresher or equal, ignoring iCloud update and pushing local to iCloud") + 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) } } else { diff --git a/firka/lib/helpers/active_account_helper.dart b/firka/lib/helpers/active_account_helper.dart new file mode 100644 index 0000000..d073ee5 --- /dev/null +++ b/firka/lib/helpers/active_account_helper.dart @@ -0,0 +1,38 @@ +import 'db/models/token_model.dart'; + +int resolveActiveAccountIndex(dynamic settings) { + try { + final dynamic profileSettings = settings.group("profile_settings"); + final dynamic accountPicker = profileSettings["e_kreta_account_picker"]; + final dynamic accountIndex = accountPicker.accountIndex; + if (accountIndex is int && accountIndex >= 0) { + return accountIndex; + } + } catch (_) { + } + + return 0; +} + +TokenModel? pickActiveToken({ + required List tokens, + required dynamic settings, + int? preferredStudentIdNorm, +}) { + if (tokens.isEmpty) return null; + + if (preferredStudentIdNorm != null) { + for (final token in tokens) { + if (token.studentIdNorm == preferredStudentIdNorm) { + return token; + } + } + } + + final accountIndex = resolveActiveAccountIndex(settings); + if (accountIndex < tokens.length) { + return tokens[accountIndex]; + } + + return tokens.first; +} diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index bb4f308..4898d85 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -7,7 +7,6 @@ import 'package:firka/helpers/api/model/class_group.dart'; import 'package:firka/helpers/api/model/homework.dart'; import 'package:firka/helpers/api/model/timetable.dart'; import 'package:firka/helpers/db/models/generic_cache_model.dart'; -import 'package:firka/helpers/db/models/homework_cache_model.dart'; import 'package:firka/helpers/db/models/timetable_cache_model.dart'; import 'package:intl/intl.dart'; import 'package:isar/isar.dart'; @@ -16,6 +15,7 @@ import '../../../main.dart'; import '../../db/models/token_model.dart'; import '../../db/util.dart'; import '../../debug_helper.dart'; +import '../../active_account_helper.dart'; import '../../watch_sync_helper.dart'; import '../consts.dart'; import '../exceptions/token.dart'; @@ -122,9 +122,12 @@ class KretaClient { ); final tokens = await initData.isar.tokenModels.where().findAll(); - if (tokens.isEmpty) return false; - - final token = tokens.first; + 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)))) { @@ -158,8 +161,13 @@ class KretaClient { if (recoveredFromiCloud) { logger.info("[Proactive] Found fresh token in iCloud, no refresh needed"); initData.tokens = await isar.tokenModels.where().findAll(); - if (initData.tokens.isNotEmpty) { - model = initData.tokens.first; + final activeToken = pickActiveToken( + tokens: initData.tokens, + settings: initData.settings, + preferredStudentIdNorm: model.studentIdNorm, + ); + if (activeToken != null) { + model = activeToken; } return true; } diff --git a/firka/lib/helpers/api/token_grant.dart b/firka/lib/helpers/api/token_grant.dart index 498fb12..7306193 100644 --- a/firka/lib/helpers/api/token_grant.dart +++ b/firka/lib/helpers/api/token_grant.dart @@ -7,6 +7,7 @@ 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'; @@ -90,7 +91,14 @@ Future extendToken(TokenModel model) async { ); if (recovered) { debugPrint('[TokenGrant] Found fresher token in iCloud! Using it instead of failing.'); - final freshToken = initData.tokens.first; + 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!, diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart index 85a5bf4..f083132 100644 --- a/firka/lib/helpers/live_activity_service.dart +++ b/firka/lib/helpers/live_activity_service.dart @@ -7,6 +7,7 @@ import 'package:firka/helpers/api/model/timetable.dart'; import 'package:firka/helpers/db/models/app_settings_model.dart'; import 'package:firka/helpers/db/widget.dart'; import 'package:firka/helpers/live_activity_manager.dart'; +import 'package:firka/helpers/active_account_helper.dart'; import 'package:firka/helpers/settings.dart'; import 'package:firka/ui/phone/screens/live_activity/live_activity_consent_screen.dart'; import 'package:flutter/material.dart'; @@ -623,7 +624,12 @@ class LiveActivityService { } final studentResp = await effectiveClient.getStudent(); - final studentName = studentResp.response?.name ?? initData.tokens.first.studentId ?? "Student"; + final activeToken = pickActiveToken( + tokens: initData.tokens, + settings: initData.settings, + preferredStudentIdNorm: effectiveClient.model.studentIdNorm, + ); + final studentName = studentResp.response?.name ?? activeToken?.studentId ?? "Student"; await onUserLogin( client: effectiveClient, @@ -1874,4 +1880,4 @@ class _GlobalSearchResult { this.firstSchoolDayAfterBreak, this.tokenExpired = false, }); -} \ No newline at end of file +} diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index 3663baf..dd7a4b8 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:isar/isar.dart'; import '../main.dart'; +import 'active_account_helper.dart'; import 'api/client/kreta_client.dart'; import 'db/models/token_model.dart'; @@ -13,6 +14,42 @@ class WatchSyncHelper { static const _watchChannel = MethodChannel('app.firka/watch_sync'); static bool _initialized = false; + static TokenModel? _resolveCurrentToken({ + List? tokens, + KretaClient? client, + }) { + final effectiveTokens = tokens ?? (initDone ? initData.tokens : null); + if (effectiveTokens == null || effectiveTokens.isEmpty) return null; + + final preferredStudentIdNorm = client?.model.studentIdNorm; + if (preferredStudentIdNorm != null) { + for (final token in effectiveTokens) { + if (token.studentIdNorm == preferredStudentIdNorm) { + return token; + } + } + } + + if (initDone) { + return pickActiveToken( + tokens: effectiveTokens, + settings: initData.settings, + preferredStudentIdNorm: preferredStudentIdNorm, + ); + } + + return effectiveTokens.first; + } + + static int? _resolveExpectedStudentIdNorm({ + List? tokens, + KretaClient? client, + }) { + final fromClient = client?.model.studentIdNorm; + if (fromClient != null) return fromClient; + return _resolveCurrentToken(tokens: tokens, client: client)?.studentIdNorm; + } + static void initialize() { if (!Platform.isIOS) return; if (_initialized) return; @@ -61,12 +98,14 @@ class WatchSyncHelper { if (recovered) { debugPrint('[WatchSync] Token recovered from iCloud, reauth flag cleared'); } else { - if (initData.tokens.isNotEmpty) { - final token = initData.tokens.first; - if (token.expiryDate != null && token.expiryDate!.isAfter(DateTime.now())) { - KretaClient.clearReauthFlag(); - debugPrint('[WatchSync] Cleared reauth flag after iCloud notification (token is valid)'); - } + final token = pickActiveToken( + tokens: initData.tokens, + settings: initData.settings, + ); + final expiryDate = token?.expiryDate; + if (expiryDate != null && expiryDate.isAfter(DateTime.now())) { + KretaClient.clearReauthFlag(); + debugPrint('[WatchSync] Cleared reauth flag after iCloud notification (token is valid)'); } } } catch (e) { @@ -80,7 +119,14 @@ class WatchSyncHelper { return {'error': 'no_token'}; } - final token = initData.tokens.first; + final token = pickActiveToken( + tokens: initData.tokens, + settings: initData.settings, + ); + if (token == null) { + debugPrint('[WatchSync] No active token available'); + return {'error': 'no_token'}; + } if (token.accessToken == null || token.refreshToken == null || @@ -137,6 +183,17 @@ class WatchSyncHelper { return {'success': false, 'error': 'no_expiry'}; } + final watchStudentIdNorm = tokenData['studentIdNorm'] as int?; + if (watchStudentIdNorm == null) { + debugPrint('[WatchSync] Watch token has no studentIdNorm'); + return {'success': false, 'error': 'no_student_id_norm'}; + } + + final expectedStudentIdNorm = _resolveExpectedStudentIdNorm( + tokens: initData.tokens, + client: initData.client, + ); + final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); if (watchExpiryDate.isBefore(DateTime.now())) { @@ -147,7 +204,7 @@ class WatchSyncHelper { debugPrint('[WatchSync] Accepting token from Watch, expiry: $watchExpiryDate'); final newToken = TokenModel.fromValues( - tokenData['studentIdNorm'] as int, + watchStudentIdNorm, tokenData['studentId'] as String, tokenData['iss'] as String, tokenData['idToken'] as String, @@ -161,13 +218,16 @@ class WatchSyncHelper { }); initData.tokens = await initData.isar.tokenModels.where().findAll(); - - if (initData.client != null) { - initData.client!.model = newToken; + final isForActiveAccount = expectedStudentIdNorm == null || + watchStudentIdNorm == expectedStudentIdNorm; + if (isForActiveAccount) { + initData.client.model = newToken; + KretaClient.clearReauthFlag(); + } else { + debugPrint( + '[WatchSync] Stored token for inactive account ($watchStudentIdNorm), active is $expectedStudentIdNorm'); } - KretaClient.clearReauthFlag(); - debugPrint('[WatchSync] Token from Watch saved successfully'); return {'success': true}; } catch (e) { @@ -263,6 +323,18 @@ class WatchSyncHelper { return false; } + final expectedStudentIdNorm = _resolveExpectedStudentIdNorm( + tokens: effectiveTokens, + client: effectiveClient, + ); + + final iCloudStudentIdNorm = tokenData['studentIdNorm'] as int?; + if (expectedStudentIdNorm != null && iCloudStudentIdNorm != expectedStudentIdNorm) { + debugPrint( + '[WatchSync] iCloud token belongs to different account ($iCloudStudentIdNorm), active is $expectedStudentIdNorm - ignoring'); + return false; + } + final iCloudExpiry = tokenData['expiryDate'] as int?; if (iCloudExpiry == null) { debugPrint('[WatchSync] iCloud token has no expiry'); @@ -276,7 +348,10 @@ class WatchSyncHelper { return false; } - final currentToken = effectiveTokens?.isNotEmpty == true ? effectiveTokens!.first : null; + final currentToken = _resolveCurrentToken( + tokens: effectiveTokens, + client: effectiveClient, + ); final localExpiry = currentToken?.expiryDate; if (localExpiry == null || iCloudExpiryDate.isAfter(localExpiry)) { @@ -302,11 +377,16 @@ class WatchSyncHelper { initData.tokens = updatedTokens; } - if (effectiveClient != null) { + if (effectiveClient != null && + (expectedStudentIdNorm == null || + newToken.studentIdNorm == expectedStudentIdNorm)) { effectiveClient.model = newToken; } - KretaClient.clearReauthFlag(); + if (expectedStudentIdNorm == null || + newToken.studentIdNorm == expectedStudentIdNorm) { + KretaClient.clearReauthFlag(); + } debugPrint('[WatchSync] Token recovered from iCloud! New expiry: $iCloudExpiryDate'); return true; @@ -368,9 +448,17 @@ class WatchSyncHelper { try { debugPrint('[WatchSync] Requesting token from Watch...'); final result = await _watchChannel.invokeMethod('requestTokenFromWatch'); + final expectedStudentIdNorm = _resolveExpectedStudentIdNorm( + tokens: effectiveTokens, + client: effectiveClient, + ); + final currentToken = _resolveCurrentToken( + tokens: effectiveTokens, + client: effectiveClient, + ); + if (result == null) { debugPrint('[WatchSync] No response from Watch'); - final currentToken = effectiveTokens.isNotEmpty ? effectiveTokens.first : null; if (currentToken != null && currentToken.accessToken != null && currentToken.refreshToken != null && @@ -385,7 +473,6 @@ class WatchSyncHelper { final tokenData = result as Map; if (tokenData.containsKey('error')) { debugPrint('[WatchSync] Watch returned error: ${tokenData['error']}'); - final currentToken = effectiveTokens.isNotEmpty ? effectiveTokens.first : null; if (currentToken != null && currentToken.accessToken != null && currentToken.refreshToken != null && @@ -403,10 +490,29 @@ class WatchSyncHelper { return; } - final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); - final currentToken = effectiveTokens.isNotEmpty ? effectiveTokens.first : null; + final watchStudentIdNorm = tokenData['studentIdNorm'] as int?; + if (watchStudentIdNorm == null) { + debugPrint('[WatchSync] Watch token has no studentIdNorm'); + return; + } - if (currentToken?.expiryDate == null || watchExpiryDate.isAfter(currentToken!.expiryDate!)) { + if (expectedStudentIdNorm != null && watchStudentIdNorm != expectedStudentIdNorm) { + debugPrint( + '[WatchSync] Watch token belongs to different account ($watchStudentIdNorm), active is $expectedStudentIdNorm - keeping active account'); + if (currentToken != null && + currentToken.accessToken != null && + currentToken.refreshToken != null && + currentToken.expiryDate != null && + !KretaClient.needsReauth) { + await _sendTokenToWatchInternal(currentToken); + } + return; + } + + final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); + + final currentExpiry = currentToken?.expiryDate; + if (currentExpiry == null || watchExpiryDate.isAfter(currentExpiry)) { debugPrint('[WatchSync] Watch has newer token, updating iPhone'); final newToken = TokenModel.fromValues( tokenData['studentIdNorm'] as int, @@ -428,16 +534,24 @@ class WatchSyncHelper { initData.tokens = updatedTokens; } - if (effectiveClient != null) { + if (effectiveClient != null && + (expectedStudentIdNorm == null || + newToken.studentIdNorm == expectedStudentIdNorm)) { effectiveClient.model = newToken; } - KretaClient.clearReauthFlag(); + if (expectedStudentIdNorm == null || + newToken.studentIdNorm == expectedStudentIdNorm) { + KretaClient.clearReauthFlag(); + } debugPrint('[WatchSync] Token updated from Watch. New expiry: $watchExpiryDate'); } else { debugPrint('[WatchSync] iPhone token is same or newer, sending to Watch'); - await _sendTokenToWatchInternal(currentToken!); + final tokenToSend = currentToken; + if (tokenToSend != null) { + await _sendTokenToWatchInternal(tokenToSend); + } } } catch (e) { debugPrint('[WatchSync] Failed to sync token from Watch: $e'); diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 5457cfe..4a2ddc7 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -37,6 +37,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'helpers/db/models/homework_cache_model.dart'; import 'helpers/update_notifier.dart'; import 'helpers/live_activity_service.dart'; +import 'helpers/active_account_helper.dart'; import 'helpers/watch_sync_helper.dart'; import 'l10n/app_localizations.dart'; import 'l10n/app_localizations_de.dart'; @@ -208,10 +209,13 @@ Future _initData(AppInitialization init) async { resetOldHomeworkCache(init.isar); if (init.tokens.isNotEmpty) { - final i = (init.settings.group("profile_settings")["e_kreta_account_picker"] - as SettingsKretenAccountPicker) - .accountIndex; - final token = (await init.isar.tokenModels.where().findAll())[i]; + final allTokens = await init.isar.tokenModels.where().findAll(); + init.tokens = allTokens; + final token = pickActiveToken(tokens: allTokens, settings: init.settings); + if (token == null) { + 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); @@ -223,8 +227,13 @@ Future _initData(AppInitialization init) async { ); if (recoveredFromiCloud) { init.tokens = await init.isar.tokenModels.where().findAll(); - if (init.tokens.isNotEmpty) { - init.client.model = init.tokens.first; + 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)'); } @@ -236,13 +245,38 @@ Future _initData(AppInitialization init) async { client: init.client, ); init.tokens = await init.isar.tokenModels.where().findAll(); - if (init.tokens.isNotEmpty) { - init.client.model = init.tokens.first; + 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) { + try { + await WatchSyncHelper.saveTokenToiCloud(selectedToken); + } catch (e) { + logger.warning('[Init] Failed to push active token to iCloud: $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) { diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index 8395e4a..67783ae 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -7,6 +7,7 @@ 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'; import 'package:firka/helpers/extensions.dart'; import 'package:firka/helpers/live_activity_service.dart'; import 'package:firka/helpers/settings.dart'; @@ -14,7 +15,6 @@ import 'package:firka/helpers/update_notifier.dart'; import 'package:firka/main.dart'; import 'package:firka/ui/model/style.dart'; import 'package:firka/ui/phone/pages/extras/main_wear_pair.dart'; -import 'package:firka/ui/phone/pages/extras/main_reauth.dart'; import 'package:firka/ui/phone/pages/extras/reauth_toast.dart'; import 'package:firka/ui/phone/pages/home/home_grades.dart'; import 'package:firka/ui/phone/pages/home/home_main.dart'; @@ -212,7 +212,11 @@ class _HomeScreenState extends FirkaState { _prefetched = true; if (Platform.isIOS) { - final token = widget.data.tokens.isNotEmpty ? widget.data.tokens.first : null; + final token = pickActiveToken( + tokens: widget.data.tokens, + settings: widget.data.settings, + preferredStudentIdNorm: widget.data.client.model.studentIdNorm, + ); final tokenExpiry = token?.expiryDate; final isTokenExpiredOrExpiring = tokenExpiry == null || tokenExpiry.isBefore(DateTime.now().add(const Duration(minutes: 5))); @@ -220,7 +224,7 @@ class _HomeScreenState extends FirkaState { if (isTokenExpiredOrExpiring || KretaClient.needsReauth) { logger.info('[Home] Token expired/expiring or needsReauth, trying iCloud recovery...'); - const delays = [1, 5, 10, 30, 60]; + const delays = [1, 5, 10, 5, 10]; for (int attempt = 0; attempt < delays.length; attempt++) { await Future.delayed(Duration(seconds: delays[attempt])); @@ -233,8 +237,13 @@ class _HomeScreenState extends FirkaState { if (recovered) { widget.data.tokens = await widget.data.isar.tokenModels.where().findAll(); - if (widget.data.tokens.isNotEmpty) { - widget.data.client.model = widget.data.tokens.first; + 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)');