Add force account-switch and iCloud recovery

Introduce forced account-switch handling and improve token selection/recovery logic.

- WatchConnectivityManager & ReauthRequiredView: parse sentAtMs robustly and compute a shouldForceAccountSwitch flag when an incoming token is for a different account; pass forceAccountSwitch to TokenManager.saveToken and include it in logs.
- TokenManager: add localTokenFromKeychainAndFile helper, prefer the active account when selecting a local token, and refine loadToken to prefer active-account tokens but fall back to the freshest available. saveToken now accepts forceAccountSwitch and will persist a token even if it switches accounts when requested. Adjust iCloud handling to skip iCloud token only if a local active-account token exists; improve diagnostic logging.
- WatchToken: enhance isNewer comparison to consider effectiveUpdatedAt and tokenVersion before falling back to expiryDate.
- HomeScreen (phone): import watch_sync_helper and run a secondary iCloud recovery pass on startup (with a short delay) to apply a fresher iCloud token to the app state if needed.

These changes aim to make cross-device token sync more robust, correctly handle account switches triggered from the watch, and recover stale auth state from iCloud on app startup.
This commit is contained in:
Horváth Gergely
2026-02-11 13:09:32 +01:00
parent 8af53422dc
commit 8f28fa328c
5 changed files with 190 additions and 39 deletions

View File

@@ -328,16 +328,30 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
}
let token = try decoder.decode(WatchToken.self, from: jsonData)
let currentToken = TokenManager.shared.loadToken()
let shouldForceAccountSwitch: Bool
if incomingSentAtMs > 0,
let currentToken,
!token.isSameAccount(as: currentToken) {
shouldForceAccountSwitch = true
} else {
shouldForceAccountSwitch = false
}
if incomingSentAtMs <= 0,
let currentToken = TokenManager.shared.loadToken(),
let currentToken,
!token.isNewer(than: currentToken) {
print("[Watch] Ignoring stale token_update without sentAtMs")
return
}
print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs))")
print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs), forceSwitch: \(shouldForceAccountSwitch))")
try TokenManager.shared.saveToken(token, syncToICloud: false)
try TokenManager.shared.saveToken(
token,
syncToICloud: false,
forceAccountSwitch: shouldForceAccountSwitch
)
print("[Watch] Token saved successfully")
if incomingSentAtMs > 0 {
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)

View File

@@ -258,6 +258,15 @@ struct ReauthRequiredView: View {
private func processAuthData(_ authDict: [String: Any]) {
do {
func parseInt64(_ value: Any?) -> Int64? {
if let value = value as? Int64 { return value }
if let value = value as? Int { return Int64(value) }
if let value = value as? Double { return Int64(value) }
if let value = value as? String, let parsed = Int64(value) { return parsed }
return nil
}
let incomingSentAtMs = parseInt64(authDict["sentAtMs"]) ?? 0
let jsonData = try JSONSerialization.data(withJSONObject: authDict)
let decoder = JSONDecoder()
@@ -268,7 +277,21 @@ struct ReauthRequiredView: View {
}
let token = try decoder.decode(WatchToken.self, from: jsonData)
try TokenManager.shared.saveToken(token, syncToICloud: false)
let currentToken = TokenManager.shared.loadToken()
let shouldForceAccountSwitch: Bool
if incomingSentAtMs > 0,
let currentToken,
!token.isSameAccount(as: currentToken) {
shouldForceAccountSwitch = true
} else {
shouldForceAccountSwitch = false
}
try TokenManager.shared.saveToken(
token,
syncToICloud: false,
forceAccountSwitch: shouldForceAccountSwitch
)
DataStore.shared.checkTokenState()
DataStore.shared.clearError()

View File

@@ -99,34 +99,45 @@ class TokenManager {
UserDefaults.standard.set(studentIdNorm, forKey: activeStudentIdNormKey)
}
private func localTokenFromKeychainAndFile(preferredStudentIdNorm: Int64? = nil) -> WatchToken? {
let keychainToken = loadTokenFromKeychain()
let fileToken = loadTokenFromFile()
var candidates: [WatchToken] = []
if let keychainToken { candidates.append(keychainToken) }
if let fileToken { candidates.append(fileToken) }
if let preferredStudentIdNorm {
let filtered = candidates.filter { $0.studentIdNorm == preferredStudentIdNorm }
if !filtered.isEmpty {
candidates = filtered
}
}
return candidates.dropFirst().reduce(candidates.first) { best, candidate in
guard let best else { return candidate }
return candidate.isNewer(than: best) ? candidate : best
}
}
private init() {
iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in
guard let self = self else { return }
let preferredStudentIdNorm = self.getActiveStudentIdNorm()
let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60)
let preferredLocalToken = self.localTokenFromKeychainAndFile(
preferredStudentIdNorm: preferredStudentIdNorm
)
if let preferredStudentIdNorm,
iCloudToken.studentIdNorm != preferredStudentIdNorm {
iCloudToken.studentIdNorm != preferredStudentIdNorm,
preferredLocalToken != nil {
print("[TokenManager] Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
return
}
let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60)
let keychainToken = self.loadTokenFromKeychain()
let fileToken = self.loadTokenFromFile()
var localCandidates: [WatchToken] = []
if let keychainToken { localCandidates.append(keychainToken) }
if let fileToken { localCandidates.append(fileToken) }
if let preferredStudentIdNorm {
let filtered = localCandidates.filter { $0.studentIdNorm == preferredStudentIdNorm }
if !filtered.isEmpty {
localCandidates = filtered
}
}
let localToken = localCandidates.dropFirst().reduce(localCandidates.first) { best, candidate in
guard let best else { return candidate }
return candidate.isNewer(than: best) ? candidate : best
}
let localToken = preferredLocalToken ?? self.localTokenFromKeychainAndFile()
if let localToken = localToken {
if iCloudToken.isNewer(than: localToken) {
@@ -186,7 +197,7 @@ class TokenManager {
return containerURL.appendingPathComponent(tokenFileName)
}
// MARK: - Load Token (fresher-wins strategy)
// MARK: - Load Token (active-account first)
func loadToken() -> WatchToken? {
let iCloudToken = iCloudTokenManager.shared.loadToken()
let keychainToken = loadTokenFromKeychain()
@@ -203,19 +214,24 @@ class TokenManager {
}
let preferredStudentIdNorm = getActiveStudentIdNorm()
let preferredCandidates: [(token: WatchToken, source: String)]
let freshest: (token: WatchToken, source: String)
if let preferredStudentIdNorm {
let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm }
preferredCandidates = filtered.isEmpty ? candidates : filtered
if filtered.isEmpty {
if let preferredFreshest = filtered.dropFirst().reduce(filtered.first) { best, candidate in
guard let best else { return candidate }
return candidate.token.isNewer(than: best.token) ? candidate : best
} {
freshest = preferredFreshest
} else {
print("[TokenManager] Active account token not found locally, falling back to freshest available account")
freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
}
}
} else {
preferredCandidates = candidates
}
let freshest = preferredCandidates.dropFirst().reduce(preferredCandidates[0]) { currentBest, candidate in
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
}
}
setActiveStudentIdNorm(freshest.token.studentIdNorm)
@@ -224,7 +240,7 @@ class TokenManager {
formatter.timeZone = TimeZone.current
print("[TokenManager] Token sources found: \(candidates.map { "\($0.source): \($0.token.studentIdNorm) @ \(formatter.string(from: $0.token.expiryDate))" }.joined(separator: ", "))")
print("[TokenManager] Using freshest token from \(freshest.source) (expiry: \(formatter.string(from: freshest.token.expiryDate)))")
print("[TokenManager] Using selected token from \(freshest.source) (expiry: \(formatter.string(from: freshest.token.expiryDate)))")
if keychainToken == nil ||
keychainToken!.studentIdNorm != freshest.token.studentIdNorm ||
@@ -269,10 +285,18 @@ class TokenManager {
}
// MARK: - Save Token
func saveToken(_ token: WatchToken, syncToICloud: Bool = false) throws {
if let currentToken = loadToken(), !token.isNewer(than: currentToken) {
print("[TokenManager] Ignoring stale or same token save attempt")
return
func saveToken(
_ token: WatchToken,
syncToICloud: Bool = false,
forceAccountSwitch: Bool = false
) throws {
if let currentToken = loadToken() {
if forceAccountSwitch && !token.isSameAccount(as: currentToken) {
print("[TokenManager] Forcing token save for explicit account switch (\(currentToken.studentIdNorm) -> \(token.studentIdNorm))")
} else if !token.isNewer(than: currentToken) {
print("[TokenManager] Ignoring stale or same token save attempt")
return
}
}
print("[TokenManager] Saving token locally (Keychain + file)")
@@ -515,8 +539,13 @@ class TokenManager {
if let iCloudToken = iCloudTokenManager.shared.loadToken() {
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
iCloudToken.studentIdNorm != preferredStudentIdNorm {
print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
continue
if localTokenFromKeychainAndFile(
preferredStudentIdNorm: preferredStudentIdNorm
) != nil {
print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
continue
}
print("[TokenManager] Step 3: Active account token missing locally, considering different-account iCloud token")
}
iCloudHasToken = true
if iCloudToken.expiryDate > Date() {

View File

@@ -66,7 +66,34 @@ struct WatchToken: Codable {
func isNewer(than other: WatchToken) -> Bool {
if !isSameAccount(as: other) {
return expiryDate > other.expiryDate
let incomingUpdatedAt = effectiveUpdatedAtMs
let currentUpdatedAt = other.effectiveUpdatedAtMs
if let incomingUpdatedAt, let currentUpdatedAt, incomingUpdatedAt != currentUpdatedAt {
return incomingUpdatedAt > currentUpdatedAt
}
if let _ = incomingUpdatedAt, currentUpdatedAt == nil {
return true
}
if incomingUpdatedAt == nil, let _ = currentUpdatedAt {
return false
}
let incomingVersion = effectiveTokenVersion
let currentVersion = other.effectiveTokenVersion
if let incomingVersion, let currentVersion, incomingVersion != currentVersion {
return incomingVersion > currentVersion
}
if let _ = incomingVersion, currentVersion == nil {
return true
}
if incomingVersion == nil, let _ = currentVersion {
return false
}
if expiryDate != other.expiryDate {
return expiryDate > other.expiryDate
}
return false
}
let incomingVersion = effectiveTokenVersion

View File

@@ -9,6 +9,7 @@ import 'package:firka/helpers/extensions.dart';
import 'package:firka/helpers/live_activity_service.dart';
import 'package:firka/helpers/settings.dart';
import 'package:firka/helpers/update_notifier.dart';
import 'package:firka/helpers/watch_sync_helper.dart';
import 'package:firka/main.dart';
import 'package:firka/ui/model/style.dart';
import 'package:firka/ui/phone/pages/extras/main_wear_pair.dart';
@@ -87,6 +88,7 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
bool _disposed = false;
bool _preloadDone = false;
int forcedNav = 0;
bool _didRunSecondaryICloudRecovery = false;
final RefreshController _refreshController =
RefreshController(initialRefresh: false);
@@ -201,12 +203,68 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
}
}
Future<void> _runSecondaryICloudRecoveryIfNeeded() async {
if (!Platform.isIOS || _didRunSecondaryICloudRecovery) return;
_didRunSecondaryICloudRecovery = true;
final activeToken = pickActiveToken(
tokens: widget.data.tokens,
settings: widget.data.settings,
preferredStudentIdNorm: widget.data.client.model.studentIdNorm,
);
final now = DateTime.now();
final shouldRunRecovery = KretaClient.needsReauth ||
activeToken == null ||
activeToken.expiryDate == null ||
activeToken.expiryDate!.isBefore(now.add(const Duration(seconds: 60)));
if (!shouldRunRecovery) {
return;
}
logger.info(
'[Home] Secondary iCloud recovery scheduled (5s delay, startup safety pass)');
await Future.delayed(const Duration(seconds: 5));
if (_disposed) return;
try {
final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud(
isar: widget.data.isar,
tokens: widget.data.tokens,
client: widget.data.client,
);
if (!recovered) {
logger.info('[Home] Secondary iCloud recovery found no fresher token');
return;
}
final refreshedTokens = initDone ? initData.tokens : widget.data.tokens;
widget.data.tokens = refreshedTokens;
final selectedToken = pickActiveToken(
tokens: refreshedTokens,
settings: widget.data.settings,
preferredStudentIdNorm: widget.data.client.model.studentIdNorm,
);
if (selectedToken != null) {
widget.data.client.model = selectedToken;
}
KretaClient.clearReauthFlag();
logger.info('[Home] Secondary iCloud recovery applied a fresher token');
} catch (e) {
logger.warning('[Home] Secondary iCloud recovery failed: $e');
}
}
void prefetch() async {
if (_prefetched) return;
try {
_prefetched = true;
await _runSecondaryICloudRecoveryIfNeeded();
try {
await widget.data.client.refreshTokenProactively().timeout(
const Duration(seconds: 60),