forked from firka/firka
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user