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.
This commit is contained in:
Horváth Gergely
2026-02-10 15:04:32 +01:00
parent eb1e4b4cfd
commit 60375e93d1
13 changed files with 346 additions and 101 deletions

View File

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

View File

@@ -111,7 +111,7 @@ class BackgroundRefreshManager {
}
func handleBackgroundRefresh() async {
await DataStore.shared.refreshAll()
await DataStore.shared.refreshAllWithRecovery()
WidgetCenter.shared.reloadAllTimelines()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<TokenGrantResponse> 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!,

View File

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

View File

@@ -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<TokenModel>? 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<TokenModel>? 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<dynamic, dynamic>;
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');

View File

@@ -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<void> _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<void> _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<void> _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) {

View File

@@ -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<HomeScreen> {
_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<HomeScreen> {
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<HomeScreen> {
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)');