From 6c67d22fb874d4da5888db78c2e0a83da3677e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Tue, 10 Feb 2026 15:04:32 +0100 Subject: [PATCH] Add active account handling & token recovery Introduce active account selection and robust token recovery across watch and phone code. Adds a new Dart helper (active_account_helper.dart) to resolve the active account/token and updates KretaClient, token refresh/grant flows, live activity, watch sync, main init and home UI to use pickActiveToken. On the watch side, implement refreshAllWithRecovery in DataStore and replace direct refreshAll calls with refreshAllWithRecovery everywhere (ContentView, BackgroundRefreshManager, WatchConnectivityManager, HomeView). Rewrite the token recovery logic to retry (with delays), prefer fresher iCloud/file/keychain tokens, attempt iPhone retrieval, and better distinguish transient network errors from permanent token invalidation. Also update TokenManager to choose the freshest local token between keychain and file, and add small sync/NULL-safety fixes and delay tweaks in home_screen to improve iCloud recovery behavior. These changes aim to avoid unnecessary reauth prompts, handle intermittent network issues, and support multi-account setups by always using the active token. --- .../FirkaWatch Watch App/ContentView.swift | 15 +- .../Services/BackgroundRefreshManager.swift | 2 +- .../Services/DataStore.swift | 104 ++++++----- .../Services/WatchConnectivityManager.swift | 2 +- .../FirkaWatch Watch App/Views/HomeView.swift | 2 +- firka/ios/Shared/API/TokenManager.swift | 13 +- firka/lib/helpers/active_account_helper.dart | 38 ++++ .../lib/helpers/api/client/kreta_client.dart | 20 ++- firka/lib/helpers/api/token_grant.dart | 10 +- firka/lib/helpers/live_activity_service.dart | 10 +- firka/lib/helpers/watch_sync_helper.dart | 162 +++++++++++++++--- firka/lib/main.dart | 50 +++++- .../ui/phone/screens/home/home_screen.dart | 19 +- 13 files changed, 346 insertions(+), 101 deletions(-) create mode 100644 firka/lib/helpers/active_account_helper.dart 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)');