forked from firka/firka
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ class BackgroundRefreshManager {
|
||||
}
|
||||
|
||||
func handleBackgroundRefresh() async {
|
||||
await DataStore.shared.refreshAll()
|
||||
await DataStore.shared.refreshAllWithRecovery()
|
||||
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
38
firka/lib/helpers/active_account_helper.dart
Normal file
38
firka/lib/helpers/active_account_helper.dart
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)');
|
||||
|
||||
Reference in New Issue
Block a user