From 61953b68d20097654e39fc97322697b4525d6b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Mon, 16 Feb 2026 19:05:17 +0100 Subject: [PATCH] Add shared session/language state & refresh leases Introduce shared session and language state plus cross-device refresh leases to improve Watch/iPhone sync. Adds SharedSessionStateManager, SharedLanguageStateManager and RefreshLeaseManager (keychain-backed) and exposes accessGroup via SharedKeychainManager. Wire shared state into WatchSessionManager (publish/load language and session state, immediate token send via message + userInfo + applicationContext, reachability check, lease-related Flutter handlers) and into WatchConnectivityManager (parse versions, apply immediate token updates). Update DataStore and WatchL10n to reconcile shared session/language state, handle stale account switching, and request tokens from phone when needed. Add TokenManager watch-side lease wrapper for refresh coordination, UI tweaks for pairing/no-token messages and icons, small fixes (date parsing in widget provider, background refresh cadence to ~15 min, time-since localization keys and usage), and various helper utilities for parsing int64 and state versioning. --- .../FirkaWatch Watch App/ContentView.swift | 36 +- .../Localization/WatchL10n.swift | 91 ++- .../Services/DataStore.swift | 95 +++- .../Services/WatchConnectivityManager.swift | 55 +- .../FirkaWatch Watch App/Views/HomeView.swift | 26 +- .../Providers/TimetableProvider.swift | 7 +- firka/ios/Runner/AppDelegate.swift | 6 +- firka/ios/Runner/WatchSessionManager.swift | 259 ++++++++- .../Shared/API/SharedKeychainManager.swift | 521 ++++++++++++++++++ firka/ios/Shared/API/TokenManager.swift | 156 +++++- .../lib/helpers/api/client/kreta_client.dart | 44 +- firka/lib/helpers/live_activity_service.dart | 24 +- firka/lib/helpers/watch_sync_helper.dart | 219 +++++++- firka/lib/main.dart | 6 + .../screens/settings/settings_screen.dart | 100 ++++ 15 files changed, 1545 insertions(+), 100 deletions(-) diff --git a/firka/ios/FirkaWatch Watch App/ContentView.swift b/firka/ios/FirkaWatch Watch App/ContentView.swift index 36810e56..9308dc5a 100644 --- a/firka/ios/FirkaWatch Watch App/ContentView.swift +++ b/firka/ios/FirkaWatch Watch App/ContentView.swift @@ -44,6 +44,8 @@ struct ContentView: View { } } .task { + dataStore.reconcileSharedSessionState() + WatchL10n.shared.reconcileFromSharedState() dataStore.checkTokenState() dataStore.loadFromCache() if dataStore.hasToken { @@ -54,6 +56,8 @@ struct ContentView: View { } .onChange(of: scenePhase) { oldPhase, newPhase in if newPhase == .active && oldPhase != .active { + dataStore.reconcileSharedSessionState() + WatchL10n.shared.reconcileFromSharedState() if shouldAutoRefresh { print("[Watch] App came to foreground, data is stale (>10 min), refreshing...") Task { @@ -65,7 +69,10 @@ struct ContentView: View { } } .onReceive(staleCheckTimer) { _ in - if scenePhase == .active && shouldAutoRefresh && !dataStore.isLoading { + guard scenePhase == .active else { return } + dataStore.reconcileSharedSessionState() + WatchL10n.shared.reconcileFromSharedState() + if shouldAutoRefresh && !dataStore.isLoading { print("[Watch] Data became stale (>10 min), auto-refreshing...") Task { await dataStore.refreshAllWithRecovery() @@ -124,22 +131,41 @@ struct ContentView: View { struct PairingView: View { var onRequestToken: (() -> Void)? + private var isWatchSystemPaired: Bool { + guard WCSession.isSupported() else { return false } + return WCSession.default.isCompanionAppInstalled + } + + private var titleKey: String { + isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone" + } + + private var descriptionKey: String { + isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone" + } + + private var iconName: String { + isWatchSystemPaired + ? "person.crop.circle.badge.exclamationmark" + : "iphone.and.arrow.right.inward" + } + var body: some View { VStack(spacing: 16) { - Image(systemName: "iphone.and.arrow.right.inward") + Image(systemName: iconName) .font(.system(size: 50)) .foregroundColor(.blue) - Text("pair_with_iphone".localized) + Text(titleKey.localized) .font(.headline) - Text("open_firka_on_iphone".localized) + Text(descriptionKey.localized) .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) - if WCSession.default.isReachable { + if isWatchSystemPaired && WCSession.default.isReachable { Button("sync_button".localized) { onRequestToken?() } diff --git a/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift b/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift index 3a5026c1..2784cf64 100644 --- a/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift +++ b/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift @@ -30,6 +30,7 @@ class WatchL10n { private let languageKey = "watch_language" private let syncWithiPhoneKey = "watch_sync_language_with_iphone" + private let lastAppliedSharedLanguageVersionKey = "watch_last_applied_shared_language_version" private static let appGroupID = "group.app.firka.firkaa" private var appGroupDefaults: UserDefaults? { UserDefaults(suiteName: Self.appGroupID) @@ -45,8 +46,9 @@ class WatchL10n { var syncWithiPhone: Bool { didSet { UserDefaults.standard.set(syncWithiPhone, forKey: syncWithiPhoneKey) + appGroupDefaults?.set(syncWithiPhone, forKey: syncWithiPhoneKey) if syncWithiPhone { - requestLanguageFromiPhone() + refreshFromiPhoneAndSharedState() } } } @@ -56,7 +58,13 @@ class WatchL10n { private init() { let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu" self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian - self.syncWithiPhone = UserDefaults.standard.bool(forKey: syncWithiPhoneKey) + if let storedSyncPref = UserDefaults.standard.object(forKey: syncWithiPhoneKey) as? Bool { + self.syncWithiPhone = storedSyncPref + } else { + self.syncWithiPhone = true + UserDefaults.standard.set(true, forKey: syncWithiPhoneKey) + appGroupDefaults?.set(true, forKey: syncWithiPhoneKey) + } appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey) loadStrings() } @@ -71,11 +79,60 @@ class WatchL10n { WidgetCenter.shared.reloadAllTimelines() } - func updateFromiPhone(languageCode: String) { + func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) { guard syncWithiPhone else { return } - if let language = WatchLanguage(rawValue: languageCode) { - setLanguage(language) + let lastAppliedVersion = lastAppliedSharedLanguageVersion() + if let sharedStateVersion, + sharedStateVersion > 0, + sharedStateVersion < lastAppliedVersion { + print("[WatchL10n] Ignoring stale WC language update (version: \(sharedStateVersion), lastApplied: \(lastAppliedVersion))") + return } + + if let language = WatchLanguage(rawValue: languageCode) { + if language != currentLanguage { + setLanguage(language) + } + if let sharedStateVersion, sharedStateVersion > 0 { + setLastAppliedSharedLanguageVersion(max(lastAppliedVersion, sharedStateVersion)) + } + } + } + + private 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 + } + + private func lastAppliedSharedLanguageVersion() -> Int64 { + parseInt64(UserDefaults.standard.object(forKey: lastAppliedSharedLanguageVersionKey)) ?? 0 + } + + private func setLastAppliedSharedLanguageVersion(_ value: Int64) { + UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey) + } + + func reconcileFromSharedState() { + guard syncWithiPhone else { return } + guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return } + let lastAppliedVersion = lastAppliedSharedLanguageVersion() + guard sharedState.stateVersion > lastAppliedVersion else { return } + + if let language = WatchLanguage(rawValue: sharedState.languageCode) { + if language != currentLanguage { + setLanguage(language) + } + setLastAppliedSharedLanguageVersion(sharedState.stateVersion) + } + } + + func refreshFromiPhoneAndSharedState() { + guard syncWithiPhone else { return } + requestLanguageFromiPhone() + reconcileFromSharedState() } private func requestLanguageFromiPhone() { @@ -113,12 +170,20 @@ class WatchL10n { "no_more_lessons": "Ma nincs több órád", "pair_with_iphone": "Párosítsd az iPhone-oddal", "open_firka_on_iphone": "Nyisd meg a Firka appot az iPhone-odon", + "login_on_iphone": "Jelentkezz be iPhone-on", + "open_and_login_on_iphone": "Nyisd meg a Firka appot iPhone-on, és lépj be egy fiókba", "updated": "Frissítve: %@", "minutes": "perc", "time_now": "most", "time_hours_minutes": "%d ó %d p", "time_hours": "%d óra", "time_minutes_only": "%d perc", + "time_since_minutes_one": "1 perce", + "time_since_minutes_many": "%d perce", + "time_since_hours_one": "1 órája", + "time_since_hours_many": "%d órája", + "time_since_days_one": "1 napja", + "time_since_days_many": "%d napja", // Timetable View "free_day": "Szabad nap", @@ -198,12 +263,20 @@ class WatchL10n { "no_more_lessons": "No more lessons today", "pair_with_iphone": "Pair with iPhone", "open_firka_on_iphone": "Open Firka app on your iPhone", + "login_on_iphone": "Sign in on iPhone", + "open_and_login_on_iphone": "Open Firka on your iPhone and sign in to an account", "updated": "Updated: %@", "minutes": "min", "time_now": "now", "time_hours_minutes": "%dh %dm", "time_hours": "%d hours", "time_minutes_only": "%d min", + "time_since_minutes_one": "1 min ago", + "time_since_minutes_many": "%d mins ago", + "time_since_hours_one": "1 hour ago", + "time_since_hours_many": "%d hours ago", + "time_since_days_one": "1 day ago", + "time_since_days_many": "%d days ago", // Timetable View "free_day": "Free Day", @@ -283,12 +356,20 @@ class WatchL10n { "no_more_lessons": "Keine Stunden mehr heute", "pair_with_iphone": "Mit iPhone koppeln", "open_firka_on_iphone": "Öffne Firka auf deinem iPhone", + "login_on_iphone": "Auf iPhone anmelden", + "open_and_login_on_iphone": "Öffne Firka auf deinem iPhone und melde dich mit einem Konto an", "updated": "Aktualisiert: %@", "minutes": "Min", "time_now": "jetzt", "time_hours_minutes": "%d Std %d Min", "time_hours": "%d Stunden", "time_minutes_only": "%d Min", + "time_since_minutes_one": "vor 1 Min", + "time_since_minutes_many": "vor %d Min", + "time_since_hours_one": "vor 1 Std", + "time_since_hours_many": "vor %d Std", + "time_since_days_one": "vor 1 Tag", + "time_since_days_many": "vor %d Tagen", // Timetable View "free_day": "Freier Tag", diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index 2523468b..f7a2cd61 100644 --- a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -32,6 +32,8 @@ class DataStore { private let appGroupID = "group.app.firka.firkaa" private let cacheFileName = "watch_data.json" + private let lastHandledSessionStateVersionKey = "firka.watch.last_handled_session_state_version" + private let lastHandledSessionActiveStudentIdNormKey = "firka.watch.last_handled_session_active_student_id_norm" private init() { checkTokenState() @@ -48,6 +50,72 @@ class DataStore { print("[Watch] Token state updated: hasToken = \(hasToken)") } + private 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 + } + + private func lastHandledSessionStateVersion() -> Int64 { + parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionStateVersionKey)) ?? 0 + } + + private func setLastHandledSessionStateVersion(_ value: Int64) { + UserDefaults.standard.set(value, forKey: lastHandledSessionStateVersionKey) + } + + private func lastHandledSessionActiveStudentIdNorm() -> Int64? { + parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionActiveStudentIdNormKey)) + } + + private func setLastHandledSessionActiveStudentIdNorm(_ value: Int64?) { + if let value { + UserDefaults.standard.set(value, forKey: lastHandledSessionActiveStudentIdNormKey) + } else { + UserDefaults.standard.removeObject(forKey: lastHandledSessionActiveStudentIdNormKey) + } + } + + func reconcileSharedSessionState() { + guard let state = SharedSessionStateManager.shared.loadState() else { + return + } + + let lastVersion = lastHandledSessionStateVersion() + guard state.stateVersion > lastVersion else { + return + } + + if !state.hasAnyAccount { + print("[Watch] Shared session state: no active iPhone account, clearing watch state") + clearAll() + resetRecoveryState() + setLastHandledSessionStateVersion(state.stateVersion) + setLastHandledSessionActiveStudentIdNorm(nil) + return + } + + if let activeStudentIdNorm = state.activeStudentIdNorm { + let lastHandledActiveStudentIdNorm = lastHandledSessionActiveStudentIdNorm() + if lastHandledActiveStudentIdNorm != activeStudentIdNorm { + print("[Watch] Shared session switched active account to \(activeStudentIdNorm), clearing stale cache") + clearCache() + data = nil + lastUpdated = nil + error = nil + recoveryAttempted = false + } + setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm) + } else { + setLastHandledSessionActiveStudentIdNorm(nil) + } + + setLastHandledSessionStateVersion(state.stateVersion) + checkTokenState() + } + // MARK: - Cache Loading func loadFromCache() { @@ -255,6 +323,21 @@ class DataStore { } func refreshAllWithRecovery() async { + reconcileSharedSessionState() + WatchL10n.shared.refreshFromiPhoneAndSharedState() + + let sharedActiveStudentIdNorm = SharedSessionStateManager.shared.loadState()?.activeStudentIdNorm + let localStudentIdNorm = TokenManager.shared.loadToken()?.studentIdNorm + let shouldRequestTokenFromPhone = + !hasValidToken || + (sharedActiveStudentIdNorm != nil && localStudentIdNorm != sharedActiveStudentIdNorm) + + if shouldRequestTokenFromPhone { + WatchConnectivityManager.shared.requestTokenFromPhone() + try? await Task.sleep(nanoseconds: 700_000_000) + checkTokenState() + } + await refreshAll() guard error == "token_expired" || error == "no_token" else { @@ -428,18 +511,24 @@ class DataStore { // Minutes let minutes = Int(elapsed / 60) if minutes < 60 { - return minutes == 1 ? "1 perce" : "\(minutes) perce" + return minutes == 1 + ? "time_since_minutes_one".localized + : "time_since_minutes_many".localized(minutes) } // Hours let hours = Int(elapsed / 3600) if hours < 24 { - return hours == 1 ? "1 órája" : "\(hours) órája" + return hours == 1 + ? "time_since_hours_one".localized + : "time_since_hours_many".localized(hours) } // Days let days = Int(elapsed / 86400) - return days == 1 ? "1 napja" : "\(days) napja" + return days == 1 + ? "time_since_days_one".localized + : "time_since_days_many".localized(days) } /// Returns true if data is stale (> 1 hour old or never updated) diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift index 6079924c..7a2b2d18 100644 --- a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift +++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift @@ -37,6 +37,22 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { return nil } + private 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 + } + func activate() { print("[Watch] WatchConnectivityManager.activate() called") if WCSession.isSupported() { @@ -94,6 +110,17 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { ) { print("[Watch] didReceiveMessage called: \(message)") + if let messageId = message["id"] as? String, messageId == "token_update" { + if let authDict = message["auth"] as? [String: Any] { + print("[Watch] Received immediate token_update via sendMessage") + processAuthData(authDict) + replyHandler(["success": true]) + } else { + replyHandler(["error": "no_auth"]) + } + return + } + guard let action = message["action"] as? String else { replyHandler(["error": "no_action"]) return @@ -223,8 +250,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { } if let language = context["language"] as? String { + let sharedStateVersion = + parseInt64(context["language_state_version"]) ?? + parseInt64(context["languageStateVersion"]) print("[Watch] Received language from iPhone: \(language)") - WatchL10n.shared.updateFromiPhone(languageCode: language) + WatchL10n.shared.updateFromiPhone( + languageCode: language, + sharedStateVersion: sharedStateVersion + ) } } @@ -238,8 +271,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { } case "language_update": if let language = userInfo["language"] as? String { + let sharedStateVersion = + parseInt64(userInfo["language_state_version"]) ?? + parseInt64(userInfo["languageStateVersion"]) print("[Watch] Received language_update via userInfo: \(language)") - WatchL10n.shared.updateFromiPhone(languageCode: language) + WatchL10n.shared.updateFromiPhone( + languageCode: language, + sharedStateVersion: sharedStateVersion + ) } case "reauth_required": print("[Watch] Received reauth_required notification from iPhone") @@ -322,8 +361,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { print("[Watch] Received language response from iPhone") DispatchQueue.main.async { if let language = response["language"] as? String { + let sharedStateVersion = + self.parseInt64(response["language_state_version"]) ?? + self.parseInt64(response["languageStateVersion"]) print("[Watch] Language received from iPhone: \(language)") - WatchL10n.shared.updateFromiPhone(languageCode: language) + WatchL10n.shared.updateFromiPhone( + languageCode: language, + sharedStateVersion: sharedStateVersion + ) } } }, @@ -379,6 +424,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { forceAccountSwitch: shouldForceAccountSwitch ) print("[Watch] Token saved successfully") + _ = SharedSessionStateManager.shared.publishState( + hasAnyAccount: true, + activeStudentIdNorm: token.studentIdNorm + ) if incomingSentAtMs > 0 { lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs) } diff --git a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift index 94fa3bbd..c7c9ab80 100644 --- a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WatchConnectivity internal import Combine struct HomeView: View { @@ -438,17 +439,36 @@ struct HomeView: View { // MARK: - No Token View + private var isWatchSystemPaired: Bool { + guard WCSession.isSupported() else { return false } + return WCSession.default.isCompanionAppInstalled + } + + private var noTokenTitleKey: String { + isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone" + } + + private var noTokenDescriptionKey: String { + isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone" + } + + private var noTokenIconName: String { + isWatchSystemPaired + ? "person.crop.circle.badge.exclamationmark" + : "iphone.and.arrow.right.inward" + } + private var noTokenView: some View { VStack(spacing: 12) { - Image(systemName: "iphone.and.arrow.right.inward") + Image(systemName: noTokenIconName) .font(.system(size: 44)) .foregroundColor(.blue) - Text("pair_with_iphone".localized) + Text(noTokenTitleKey.localized) .font(.headline) .multilineTextAlignment(.center) - Text("open_firka_on_iphone".localized) + Text(noTokenDescriptionKey.localized) .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) diff --git a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift index 6dbaf1d4..14103fa7 100644 --- a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift +++ b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift @@ -38,7 +38,12 @@ struct TimetableProvider: AppIntentTimelineProvider { private func parseNextSchoolDayDate(_ dateString: String?) -> Date? { guard let dateString = dateString else { return nil } - return Self.dateFormatter.date(from: dateString) + if let date = Self.dateFormatter.date(from: dateString) { + return date + } + + let trimmed = String(dateString.prefix(10)) + return Self.dateFormatter.date(from: trimmed) } func placeholder(in context: Context) -> TimetableEntry { diff --git a/firka/ios/Runner/AppDelegate.swift b/firka/ios/Runner/AppDelegate.swift index 6c25057c..64d6f1f2 100644 --- a/firka/ios/Runner/AppDelegate.swift +++ b/firka/ios/Runner/AppDelegate.swift @@ -213,12 +213,12 @@ import BackgroundTasks let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier) // IMPORTANT: iOS may delay this based on system conditions and user behavior - // The default setting is 30 minutes - request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + // Requested cadence: 15 minutes (best effort, not guaranteed by iOS) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) do { try BGTaskScheduler.shared.submit(request) - print("[AppDelegate] Background refresh scheduled for ~30 minutes from now") + print("[AppDelegate] Background refresh scheduled for ~15 minutes from now") } catch { print("[AppDelegate] Could not schedule background refresh: \(error)") } diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift index 8acd1833..4d108a0c 100644 --- a/firka/ios/Runner/WatchSessionManager.swift +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -38,12 +38,26 @@ class WatchSessionManager: NSObject, WCSessionDelegate { self?.handleSaveTokenToiCloud(arguments: call.arguments, result: result) case "isWatchAppInstalled": self?.handleIsWatchAppInstalled(result: result) + case "isWatchReachable": + self?.handleIsWatchReachable(result: result) case "clearICloudToken": self?.handleClearICloudToken(result: result) case "sendLogoutToWatch": self?.handleSendLogoutToWatch(result: result) case "watchSyncReady": self?.handleWatchSyncReady(result: result) + case "waitForPeerRefreshLease": + self?.handleWaitForPeerRefreshLease(arguments: call.arguments, result: result) + case "acquireRefreshLease": + self?.handleAcquireRefreshLease(arguments: call.arguments, result: result) + case "releaseRefreshLease": + self?.handleReleaseRefreshLease(arguments: call.arguments, result: result) + case "clearRefreshLeaseForAccount": + self?.handleClearRefreshLeaseForAccount(arguments: call.arguments, result: result) + case "clearAllRefreshLeases": + self?.handleClearAllRefreshLeases(result: result) + case "clearSharedLanguageState": + self?.handleClearSharedLanguageState(result: result) default: result(FlutterMethodNotImplemented) } @@ -88,6 +102,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return nil } + private func parseLeaseOwner(_ value: Any?) -> RefreshLeaseOwner? { + guard let raw = value as? String else { + return nil + } + return RefreshLeaseOwner(rawValue: raw) + } + private func tokenPayload(from token: WatchToken) -> [String: Any] { var tokenData: [String: Any] = [ "studentId": token.studentId, @@ -194,21 +215,49 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return } + guard WCSession.default.isWatchAppInstalled else { + print("[WatchSessionManager] No paired Watch app, skipping token send") + result(nil) + return + } + guard WCSession.default.activationState == .activated else { result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil)) return } + let session = WCSession.default + do { - WCSession.default.transferUserInfo([ - "id": "token_update", + try session.updateApplicationContext([ "auth": authData ]) - result(nil) - print("[WatchSessionManager] Token sent to Watch") } catch { - result(FlutterError(code: "TRANSFER_ERROR", message: error.localizedDescription, details: nil)) + print("[WatchSessionManager] Failed to update applicationContext for token: \(error)") } + + session.transferUserInfo([ + "id": "token_update", + "auth": authData + ]) + + if session.isReachable { + session.sendMessage( + [ + "id": "token_update", + "auth": authData + ], + replyHandler: { _ in + print("[WatchSessionManager] Token delivered to Watch via sendMessage") + }, + errorHandler: { error in + print("[WatchSessionManager] Failed immediate token send via sendMessage: \(error.localizedDescription)") + } + ) + } + + result(nil) + print("[WatchSessionManager] Token sent to Watch") } private func handleSendWidgetDataToWatch(arguments: Any?, result: @escaping FlutterResult) { @@ -237,22 +286,33 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return } - guard WCSession.default.activationState == .activated else { - result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil)) + guard WCSession.default.isWatchAppInstalled else { + print("[WatchSessionManager] No paired Watch app, skipping language publish") + result(nil) return } - do { - try WCSession.default.updateApplicationContext(["language": languageCode]) - print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext") - } catch { - print("[WatchSessionManager] Failed to update applicationContext for language: \(error)") - } + let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode) - WCSession.default.transferUserInfo([ - "id": "language_update", - "language": languageCode - ]) + if WCSession.default.activationState == .activated { + do { + try WCSession.default.updateApplicationContext([ + "language": languageCode, + "language_state_version": sharedState.stateVersion + ]) + print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext") + } catch { + print("[WatchSessionManager] Failed to update applicationContext for language: \(error)") + } + + WCSession.default.transferUserInfo([ + "id": "language_update", + "language": languageCode, + "language_state_version": sharedState.stateVersion + ]) + } else { + print("[WatchSessionManager] WCSession not active, language shared-state published only") + } result(nil) print("[WatchSessionManager] Language '\(languageCode)' sent to Watch") } @@ -325,6 +385,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return } + guard WCSession.default.isWatchAppInstalled else { + print("[WatchSessionManager] No paired Watch app, skipping token save to shared Keychain") + result(nil) + return + } + guard let accessToken = tokenData["accessToken"] as? String, let refreshToken = tokenData["refreshToken"] as? String, let idToken = tokenData["idToken"] as? String, @@ -352,7 +418,26 @@ class WatchSessionManager: NSObject, WCSessionDelegate { updatedAtMs: updatedAtMs ) - SharedKeychainManager.shared.saveToken(token) + let forceAccountSwitch = (tokenData["forceAccountSwitch"] as? Bool) == true + let didSave = SharedKeychainManager.shared.saveToken( + token, + forceAccountSwitch: forceAccountSwitch + ) + if WCSession.default.isWatchAppInstalled { + if didSave { + _ = SharedSessionStateManager.shared.publishState( + hasAnyAccount: true, + activeStudentIdNorm: studentIdNorm + ) + } else if SharedKeychainManager.shared.loadToken()?.studentIdNorm == studentIdNorm { + _ = SharedSessionStateManager.shared.publishState( + hasAnyAccount: true, + activeStudentIdNorm: studentIdNorm + ) + } else { + print("[WatchSessionManager] Token save skipped (stale/cross-account); skipping session-state publish for \(studentIdNorm)") + } + } let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" @@ -372,10 +457,136 @@ class WatchSessionManager: NSObject, WCSessionDelegate { result(installed) } + private func handleIsWatchReachable(result: @escaping FlutterResult) { + guard WCSession.isSupported() else { + result(false) + return + } + + let session = WCSession.default + let reachable = + session.isPaired && + session.isWatchAppInstalled && + session.activationState == .activated && + session.isReachable + result(reachable) + } + private func handleClearICloudToken(result: @escaping FlutterResult) { SharedKeychainManager.shared.deleteToken() SharedKeychainManager.shared.clearKVStore() + RefreshLeaseManager.shared.clearAllLeases() + SharedLanguageStateManager.shared.clearState() + if WCSession.default.isWatchAppInstalled { + _ = SharedSessionStateManager.shared.publishState( + hasAnyAccount: false, + activeStudentIdNorm: nil + ) + } else { + SharedSessionStateManager.shared.clearState() + } + result(nil) + } + + private func handleWaitForPeerRefreshLease(arguments: Any?, result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let owner = parseLeaseOwner(args["owner"]), + let studentIdNorm = parseInt64(args["studentIdNorm"]) else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid wait lease arguments", details: nil)) + return + } + + let maxWaitMs = parseInt64(args["maxWaitMs"]) ?? 150_000 + let pollMs = parseInt64(args["pollIntervalMs"]) ?? 250 + + if owner == .iphone && !WCSession.default.isWatchAppInstalled { + result([ + "ready": true, + "status": "no_watch", + "waitedMs": 0, + "leaseChanged": false + ]) + return + } + + Task { + let waitResult = await RefreshLeaseManager.shared.waitForPeerLeaseRelease( + owner: owner, + studentIdNorm: studentIdNorm, + maxWaitMs: maxWaitMs, + pollIntervalMs: pollMs + ) + DispatchQueue.main.async { + result(waitResult.asDictionary()) + } + } + } + + private func handleAcquireRefreshLease(arguments: Any?, result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let owner = parseLeaseOwner(args["owner"]), + let studentIdNorm = parseInt64(args["studentIdNorm"]) else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid acquire lease arguments", details: nil)) + return + } + + if owner == .iphone && !WCSession.default.isWatchAppInstalled { + result([ + "skipped": true, + "status": "no_watch" + ]) + return + } + + let ttlMs = parseInt64(args["ttlMs"]) ?? 120_000 + let operationId = (args["operationId"] as? String) ?? UUID().uuidString + let lease = RefreshLeaseManager.shared.acquireLease( + owner: owner, + studentIdNorm: studentIdNorm, + ttlMs: ttlMs, + operationId: operationId + ) + result([ + "operationId": lease.operationId, + "startedAtMs": lease.startedAtMs, + "expiresAtMs": lease.expiresAtMs + ]) + } + + private func handleReleaseRefreshLease(arguments: Any?, result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let owner = parseLeaseOwner(args["owner"]), + let studentIdNorm = parseInt64(args["studentIdNorm"]) else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid release lease arguments", details: nil)) + return + } + let operationId = args["operationId"] as? String + RefreshLeaseManager.shared.releaseLease( + owner: owner, + studentIdNorm: studentIdNorm, + operationId: operationId + ) + result(nil) + } + + private func handleClearRefreshLeaseForAccount(arguments: Any?, result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let studentIdNorm = parseInt64(args["studentIdNorm"]) else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing studentIdNorm", details: nil)) + return + } + RefreshLeaseManager.shared.clearLeases(studentIdNorm: studentIdNorm) + result(nil) + } + + private func handleClearAllRefreshLeases(result: @escaping FlutterResult) { + RefreshLeaseManager.shared.clearAllLeases() + result(nil) + } + + private func handleClearSharedLanguageState(result: @escaping FlutterResult) { + SharedLanguageStateManager.shared.clearState() result(nil) } @@ -498,11 +709,19 @@ class WatchSessionManager: NSObject, WCSessionDelegate { DispatchQueue.main.async { self.flutterChannel?.invokeMethod("getLanguageForWatch", arguments: nil) { result in if let languageCode = result as? String { + let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode) print("[WatchSessionManager] Sending language to Watch: \(languageCode)") - replyHandler(["language": languageCode]) + replyHandler([ + "language": languageCode, + "language_state_version": sharedState.stateVersion + ]) } else { + let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: "hu") print("[WatchSessionManager] No language from Flutter, defaulting to hu") - replyHandler(["language": "hu"]) + replyHandler([ + "language": "hu", + "language_state_version": sharedState.stateVersion + ]) } } } diff --git a/firka/ios/Shared/API/SharedKeychainManager.swift b/firka/ios/Shared/API/SharedKeychainManager.swift index b34ba92c..4b156072 100644 --- a/firka/ios/Shared/API/SharedKeychainManager.swift +++ b/firka/ios/Shared/API/SharedKeychainManager.swift @@ -20,6 +20,10 @@ class SharedKeychainManager { private init() {} + var resolvedAccessGroup: String { + accessGroup + } + private func resolveAccessGroup() -> String { let probeService = "\(service).probe" let probeAccount = "probe" @@ -279,3 +283,520 @@ class SharedKeychainManager { print("[SharedKeychain] Cleared old KV Store data") } } + +enum RefreshLeaseOwner: String { + case iphone + case watch + + var peer: RefreshLeaseOwner { + switch self { + case .iphone: + return .watch + case .watch: + return .iphone + } + } +} + +struct SharedSessionStateRecord: Codable { + let stateVersion: Int64 + let hasAnyAccount: Bool + let activeStudentIdNorm: Int64? + let updatedAtMs: Int64 + let sourceDevice: String +} + +struct SharedLanguageStateRecord: Codable { + let stateVersion: Int64 + let languageCode: String + let updatedAtMs: Int64 + let expiresAtMs: Int64 + let sourceDevice: String +} + +class SharedLanguageStateManager { + static let shared = SharedLanguageStateManager() + + private let service = "app.firka.shared.language_state" + private let account = "language_state" + private let accessGroup: String + private let maxTtlMs: Int64 = 7 * 24 * 60 * 60 * 1000 + + #if os(iOS) + private let sourceDevice = "iphone" + #elseif os(watchOS) + private let sourceDevice = "watch" + #endif + + private init() { + accessGroup = SharedKeychainManager.shared.resolvedAccessGroup + } + + private func nowMs() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) + } + + private func encode(_ state: SharedLanguageStateRecord) -> Data? { + try? JSONEncoder().encode(state) + } + + private func decode(_ data: Data) -> SharedLanguageStateRecord? { + try? JSONDecoder().decode(SharedLanguageStateRecord.self, from: data) + } + + private func keychainQueryBase() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup + ] + } + + private func loadStateFromKeychain() -> SharedLanguageStateRecord? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return decode(data) + } + + private func storeStateInKeychain(_ state: SharedLanguageStateRecord) { + guard let data = encode(state) else { + print("[SharedLanguageState] Failed to encode state for keychain") + return + } + + var deleteQuery = keychainQueryBase() + deleteQuery[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny + SecItemDelete(deleteQuery as CFDictionary) + + var addQuery = keychainQueryBase() + addQuery[kSecAttrSynchronizable as String] = kCFBooleanTrue! + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + addQuery[kSecValueData as String] = data + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + print("[SharedLanguageState] Failed to publish state to keychain: \(status)") + } + } + + private func clearKeychainState() { + var query = keychainQueryBase() + query[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny + SecItemDelete(query as CFDictionary) + } + + private func isExpired(_ state: SharedLanguageStateRecord, now: Int64) -> Bool { + state.expiresAtMs <= now + } + + func loadState() -> SharedLanguageStateRecord? { + let now = nowMs() + guard let keychainState = loadStateFromKeychain() else { + return nil + } + if isExpired(keychainState, now: now) { + clearKeychainState() + return nil + } + return keychainState + } + + @discardableResult + func publishState( + languageCode: String, + ttlMs: Int64 = 24 * 60 * 60 * 1000 + ) -> SharedLanguageStateRecord { + let now = nowMs() + let previousVersion = loadStateFromKeychain()?.stateVersion ?? 0 + let nextVersion = max(now, previousVersion + 1) + let effectiveTtl = max(min(ttlMs, maxTtlMs), 60_000) + let state = SharedLanguageStateRecord( + stateVersion: nextVersion, + languageCode: languageCode, + updatedAtMs: now, + expiresAtMs: now + effectiveTtl, + sourceDevice: sourceDevice + ) + + storeStateInKeychain(state) + + return state + } + + func clearState() { + clearKeychainState() + } +} + +class SharedSessionStateManager { + static let shared = SharedSessionStateManager() + + private let service = "app.firka.shared.session_state" + private let account = "session_state" + private let accessGroup: String + + #if os(iOS) + private let sourceDevice = "iphone" + #elseif os(watchOS) + private let sourceDevice = "watch" + #endif + + private init() { + accessGroup = SharedKeychainManager.shared.resolvedAccessGroup + } + + private func nowMs() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) + } + + private func encode(_ state: SharedSessionStateRecord) -> Data? { + try? JSONEncoder().encode(state) + } + + private func decode(_ data: Data) -> SharedSessionStateRecord? { + try? JSONDecoder().decode(SharedSessionStateRecord.self, from: data) + } + + func loadState() -> SharedSessionStateRecord? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return decode(data) + } + + @discardableResult + func publishState( + hasAnyAccount: Bool, + activeStudentIdNorm: Int64? + ) -> SharedSessionStateRecord { + let now = nowMs() + let previousVersion = loadState()?.stateVersion ?? 0 + let nextVersion = max(now, previousVersion + 1) + let state = SharedSessionStateRecord( + stateVersion: nextVersion, + hasAnyAccount: hasAnyAccount, + activeStudentIdNorm: hasAnyAccount ? activeStudentIdNorm : nil, + updatedAtMs: now, + sourceDevice: sourceDevice + ) + + if let data = encode(state) { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kCFBooleanTrue!, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData as String: data + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + print("[SharedSessionState] Failed to publish state: \(status)") + } + } else { + print("[SharedSessionState] Failed to encode state") + } + + return state + } + + func clearState() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + SecItemDelete(query as CFDictionary) + } +} + +struct RefreshLeaseRecord: Codable { + let owner: String + let studentIdNorm: Int64 + let operationId: String + let startedAtMs: Int64 + let expiresAtMs: Int64 +} + +struct RefreshLeaseWaitResult { + let ready: Bool + let status: String + let waitedMs: Int64 + let peerOperationId: String? + let leaseChanged: Bool + + func asDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "ready": ready, + "status": status, + "waitedMs": waitedMs, + "leaseChanged": leaseChanged + ] + if let peerOperationId { + dict["peerOperationId"] = peerOperationId + } + return dict + } +} + +class RefreshLeaseManager { + static let shared = RefreshLeaseManager() + + private let service = "app.firka.shared.refresh_lease" + private let accountPrefix = "lease" + private let accessGroup: String + + private init() { + accessGroup = SharedKeychainManager.shared.resolvedAccessGroup + } + + private func keyAccount( + owner: RefreshLeaseOwner, + studentIdNorm: Int64 + ) -> String { + "\(accountPrefix)_\(owner.rawValue)_\(studentIdNorm)" + } + + private func nowMs() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) + } + + private func encode(_ lease: RefreshLeaseRecord) -> Data? { + try? JSONEncoder().encode(lease) + } + + private func decode(_ data: Data) -> RefreshLeaseRecord? { + try? JSONDecoder().decode(RefreshLeaseRecord.self, from: data) + } + + func loadLease( + owner: RefreshLeaseOwner, + studentIdNorm: Int64 + ) -> RefreshLeaseRecord? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm), + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return decode(data) + } + + @discardableResult + func acquireLease( + owner: RefreshLeaseOwner, + studentIdNorm: Int64, + ttlMs: Int64, + operationId: String = UUID().uuidString + ) -> RefreshLeaseRecord { + let now = nowMs() + let clampedTtl = max(ttlMs, 5_000) + let lease = RefreshLeaseRecord( + owner: owner.rawValue, + studentIdNorm: studentIdNorm, + operationId: operationId, + startedAtMs: now, + expiresAtMs: now + clampedTtl + ) + + if let data = encode(lease) { + let account = keyAccount(owner: owner, studentIdNorm: studentIdNorm) + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kCFBooleanTrue!, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData as String: data + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + print("[RefreshLease] Failed to acquire lease for \(owner.rawValue): \(status)") + } + } else { + print("[RefreshLease] Failed to encode lease for \(owner.rawValue)") + } + + return lease + } + + func releaseLease( + owner: RefreshLeaseOwner, + studentIdNorm: Int64, + operationId: String? = nil + ) { + if let operationId, + let current = loadLease(owner: owner, studentIdNorm: studentIdNorm), + current.operationId != operationId { + return + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm), + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + SecItemDelete(query as CFDictionary) + } + + func clearLeases(studentIdNorm: Int64) { + releaseLease(owner: .iphone, studentIdNorm: studentIdNorm, operationId: nil) + releaseLease(owner: .watch, studentIdNorm: studentIdNorm, operationId: nil) + } + + func clearAllLeases() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status != errSecSuccess { + return + } + + guard let items = result as? [[String: Any]] else { + return + } + + for item in items { + guard let account = item[kSecAttrAccount as String] as? String else { + continue + } + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + SecItemDelete(deleteQuery as CFDictionary) + } + } + + func waitForPeerLeaseRelease( + owner: RefreshLeaseOwner, + studentIdNorm: Int64, + maxWaitMs: Int64, + pollIntervalMs: Int64 + ) async -> RefreshLeaseWaitResult { + let startedAt = nowMs() + var deadline = startedAt + max(maxWaitMs, 1_000) + var lastFingerprint: String? + var leaseChanged = false + + while nowMs() < deadline { + let now = nowMs() + guard let peer = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm) else { + return RefreshLeaseWaitResult( + ready: true, + status: leaseChanged ? "peer_lease_changed" : "peer_lease_missing", + waitedMs: now - startedAt, + peerOperationId: nil, + leaseChanged: leaseChanged + ) + } + + if peer.expiresAtMs <= now { + releaseLease( + owner: owner.peer, + studentIdNorm: studentIdNorm, + operationId: peer.operationId + ) + return RefreshLeaseWaitResult( + ready: true, + status: "peer_lease_expired", + waitedMs: now - startedAt, + peerOperationId: peer.operationId, + leaseChanged: leaseChanged + ) + } + + let fingerprint = "\(peer.operationId)|\(peer.startedAtMs)|\(peer.expiresAtMs)" + if let previousFingerprint = lastFingerprint, previousFingerprint != fingerprint { + leaseChanged = true + deadline = min(deadline, peer.expiresAtMs + 5_000) + lastFingerprint = fingerprint + continue + } + + lastFingerprint = fingerprint + deadline = min(deadline, peer.expiresAtMs + 5_000) + let sleepMs = max(min(pollIntervalMs, 1_000), 50) + try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000) + } + + let waited = max(nowMs() - startedAt, 0) + let peerOperation = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm)?.operationId + return RefreshLeaseWaitResult( + ready: false, + status: "timed_out", + waitedMs: waited, + peerOperationId: peerOperation, + leaseChanged: leaseChanged + ) + } +} diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift index c18cc036..b357c513 100644 --- a/firka/ios/Shared/API/TokenManager.swift +++ b/firka/ios/Shared/API/TokenManager.swift @@ -44,6 +44,13 @@ class TokenManager { private let proactiveRefreshLeadTime: TimeInterval = 5 * 60 private let minimumProactiveRefreshInterval: TimeInterval = 60 private let iCloudProbeTimeoutNs: UInt64 = 1_500_000_000 + private let refreshRequestTimeout: TimeInterval = 12 + private let refreshResourceTimeout: TimeInterval = 20 + #if os(watchOS) + private let watchRefreshLeaseTtlMs: Int64 = 180_000 + private let iPhoneRefreshLeaseMaxWaitMs: Int64 = 150_000 + private let refreshLeasePollIntervalMs: Int64 = 250 + #endif #if os(iOS) private let deviceName = "iPhone" @@ -84,6 +91,41 @@ class TokenManager { lastRecoveryFailure = nil } + #if os(watchOS) + private func withWatchRefreshLease( + studentIdNorm: Int64, + _ operation: () async throws -> T + ) async throws -> T { + let waitResult = await RefreshLeaseManager.shared.waitForPeerLeaseRelease( + owner: .watch, + studentIdNorm: studentIdNorm, + maxWaitMs: iPhoneRefreshLeaseMaxWaitMs, + pollIntervalMs: refreshLeasePollIntervalMs + ) + + guard waitResult.ready else { + print("[TokenManager] Watch refresh lease wait timed out (waited \(waitResult.waitedMs)ms, changed: \(waitResult.leaseChanged))") + throw TokenError.networkError + } + + let lease = RefreshLeaseManager.shared.acquireLease( + owner: .watch, + studentIdNorm: studentIdNorm, + ttlMs: watchRefreshLeaseTtlMs + ) + + defer { + RefreshLeaseManager.shared.releaseLease( + owner: .watch, + studentIdNorm: studentIdNorm, + operationId: lease.operationId + ) + } + + return try await operation() + } + #endif + private func getActiveStudentIdNorm() -> Int64? { if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Int64 { return value @@ -263,7 +305,24 @@ class TokenManager { return nil } - let preferredStudentIdNorm = getActiveStudentIdNorm() + var preferredStudentIdNorm = getActiveStudentIdNorm() + var requirePreferredAccount = false + #if os(watchOS) + if let sessionState = SharedSessionStateManager.shared.loadState() { + if !sessionState.hasAnyAccount { + print("[TokenManager] Shared session state indicates no active accounts, returning no token") + return nil + } + if let sharedActiveStudentIdNorm = sessionState.activeStudentIdNorm { + preferredStudentIdNorm = sharedActiveStudentIdNorm + requirePreferredAccount = true + if getActiveStudentIdNorm() != sharedActiveStudentIdNorm { + setActiveStudentIdNorm(sharedActiveStudentIdNorm) + } + } + } + #endif + let freshest: (token: WatchToken, source: String) if let preferredStudentIdNorm { let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm } @@ -273,7 +332,15 @@ class TokenManager { } { freshest = preferredFreshest } else { - print("[TokenManager] Active account token not found locally, falling back to freshest available account") + if requirePreferredAccount { + print("[TokenManager] Active shared-session account token (\(preferredStudentIdNorm)) not found yet, falling back to best available token") + #if os(watchOS) + if WCSession.default.activationState == .activated && WCSession.default.isReachable { + print("[TokenManager] iPhone reachable, requesting active account token") + WatchConnectivityManager.shared.requestTokenFromPhone() + } + #endif + } freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest } @@ -326,6 +393,11 @@ class TokenManager { // MARK: - Delete Token func deleteToken() { print("[TokenManager] Deleting token from all storage locations") + if let previousToken = loadToken() { + RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm) + } else { + RefreshLeaseManager.shared.clearAllLeases() + } deleteTokenFromKeychain() SharedKeychainManager.shared.deleteToken() UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey) @@ -340,7 +412,8 @@ class TokenManager { syncToSharedKeychain: Bool = false, forceAccountSwitch: Bool = false ) throws { - if let currentToken = loadToken() { + let currentToken = loadToken() + if let currentToken { 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) { @@ -349,6 +422,12 @@ class TokenManager { } } + if forceAccountSwitch, + let currentToken, + !token.isSameAccount(as: currentToken) { + RefreshLeaseManager.shared.clearLeases(studentIdNorm: currentToken.studentIdNorm) + } + print("[TokenManager] Saving token locally (Keychain + file)") setActiveStudentIdNorm(token.studentIdNorm) @@ -809,6 +888,32 @@ class TokenManager { #endif private func refreshTokenInternal(_ token: WatchToken) async throws -> WatchToken { + #if os(watchOS) + return try await withWatchRefreshLease(studentIdNorm: token.studentIdNorm) { + let response = try await performTokenRefresh( + refreshToken: token.refreshToken, + instituteCode: token.iss + ) + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs + + let newToken = WatchToken( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + idToken: response.idToken, + iss: token.iss, + studentId: token.studentId, + studentIdNorm: token.studentIdNorm, + expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60), + tokenVersion: tokenVersion, + updatedAtMs: nowMs + ) + + try saveToken(newToken, syncToSharedKeychain: true) + WatchConnectivityManager.shared.sendTokenToiPhoneInBackground() + return newToken + } + #else let response = try await performTokenRefresh( refreshToken: token.refreshToken, instituteCode: token.iss @@ -829,12 +934,8 @@ class TokenManager { ) try saveToken(newToken, syncToSharedKeychain: true) - - #if os(watchOS) - WatchConnectivityManager.shared.sendTokenToiPhoneInBackground() - #endif - return newToken + #endif } // MARK: - Refresh Token @@ -843,6 +944,32 @@ class TokenManager { throw TokenError.noToken } + #if os(watchOS) + return try await withWatchRefreshLease(studentIdNorm: currentToken.studentIdNorm) { + let response = try await performTokenRefresh( + refreshToken: currentToken.refreshToken, + instituteCode: currentToken.iss + ) + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs + + let newToken = WatchToken( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + idToken: response.idToken, + iss: currentToken.iss, + studentId: currentToken.studentId, + studentIdNorm: currentToken.studentIdNorm, + expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60), + tokenVersion: tokenVersion, + updatedAtMs: nowMs + ) + + try saveToken(newToken, syncToSharedKeychain: true) + WatchConnectivityManager.shared.sendTokenToiPhoneInBackground() + return newToken + } + #else let response = try await performTokenRefresh( refreshToken: currentToken.refreshToken, instituteCode: currentToken.iss @@ -863,12 +990,8 @@ class TokenManager { ) try saveToken(newToken, syncToSharedKeychain: true) - - #if os(watchOS) - WatchConnectivityManager.shared.sendTokenToiPhoneInBackground() - #endif - return newToken + #endif } // MARK: - Private Helper Methods @@ -885,6 +1008,7 @@ class TokenManager { request.setValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type") request.setValue(userAgent, forHTTPHeaderField: "User-Agent") request.setValue("*/*", forHTTPHeaderField: "Accept") + request.timeoutInterval = refreshRequestTimeout let formParameters: [String: String] = [ "institute_code": instituteCode, @@ -896,7 +1020,11 @@ class TokenManager { request.httpBody = encodeFormData(formParameters).data(using: .utf8) do { - let (data, response) = try await URLSession.shared.data(for: request) + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = refreshRequestTimeout + configuration.timeoutIntervalForResource = refreshResourceTimeout + let session = URLSession(configuration: configuration) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw TokenError.networkError diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index 30db7b55..98aa8ff2 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -84,6 +84,44 @@ class KretaClient { KretaClient(this.model, this.isar); + Future _refreshModelWithCrossDeviceLease( + TokenModel sourceToken) async { + final studentIdNorm = sourceToken.studentIdNorm; + String? leaseOperationId; + + try { + if (Platform.isIOS && studentIdNorm != null) { + final watchInstalled = await WatchSyncHelper.isWatchAppInstalled(); + if (watchInstalled) { + final leaseReady = await WatchSyncHelper.waitForWatchRefreshLease( + studentIdNorm: studentIdNorm, + ); + if (!leaseReady) { + throw Exception('watch_refresh_lease_timeout'); + } + leaseOperationId = await WatchSyncHelper.acquireIPhoneRefreshLease( + studentIdNorm: studentIdNorm, + ); + if (leaseOperationId == null) { + throw Exception('iphone_refresh_lease_acquire_failed'); + } + } + } + + final extended = await extendToken(sourceToken); + return TokenModel.fromResp(extended); + } finally { + if (Platform.isIOS && + studentIdNorm != null && + leaseOperationId != null) { + await WatchSyncHelper.releaseIPhoneRefreshLease( + studentIdNorm: studentIdNorm, + operationId: leaseOperationId, + ); + } + } + } + Future _syncTokenToAppleTargets(TokenModel token) async { if (!Platform.isIOS) return; if (token.accessToken == null || @@ -155,8 +193,7 @@ class KretaClient { logger.info("[Recovery] Step 1: Trying local token refresh..."); try { - var extended = await extendToken(model); - var tokenModel = TokenModel.fromResp(extended); + var tokenModel = await _refreshModelWithCrossDeviceLease(model); await isar.writeTxn(() async { await isar.tokenModels.put(tokenModel); @@ -222,8 +259,7 @@ class KretaClient { logger.info( "[Recovery] Found iCloud token close to expiry, trying refresh..."); try { - var extended = await extendToken(model); - var tokenModel = TokenModel.fromResp(extended); + var tokenModel = await _refreshModelWithCrossDeviceLease(model); await isar.writeTxn(() async { await isar.tokenModels.put(tokenModel); diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart index f0831323..94952bf9 100644 --- a/firka/lib/helpers/live_activity_service.dart +++ b/firka/lib/helpers/live_activity_service.dart @@ -377,21 +377,6 @@ class LiveActivityService { } } - /// Check if there are any remaining lessons today - static bool _hasRemainingLessonsToday(List lessons) { - final now = DateTime.now(); - final todayLessons = lessons.where((lesson) { - final uid = lesson.uid.toLowerCase(); - return lesson.date == now.toIso8601String().split('T').first && - lesson.end.isAfter(now) && - (uid.contains('orarendiora') || - uid.contains('tanitasiora') || - uid.contains('uresora')); - }).toList(); - - return todayLessons.isNotEmpty; - } - /// Perform background fetch - fetch fresh timetable from KRÉTA API and send to backend /// This is called by iOS BGTaskScheduler when the app is in background static Future _performBackgroundFetch() async { @@ -538,14 +523,7 @@ class LiveActivityService { if (success) { await _saveLastUpdate(); _logger.info('Background fetch: successfully sent timetable to backend'); - - if (!_hasRemainingLessonsToday(allLessons)) { - _logger.info('Background fetch: no remaining lessons today, cancelling future background fetches until app reopens'); - await cancelBackgroundFetch(); - } else { - _logger.info('Background fetch: remaining lessons today, will continue background fetches'); - } - + _logger.info('Background fetch: keeping periodic scheduling active'); return true; } else { _logger.warning('Background fetch: failed to send timetable to backend'); diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index 2d78a5b0..2024df57 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -15,11 +15,15 @@ import 'db/models/token_model.dart'; /// Helper class for Watch ↔ iPhone token sync class WatchSyncHelper { static const _watchChannel = MethodChannel('app.firka/watch_sync'); + static const _leaseOwnerIPhone = 'iphone'; static bool _initialized = false; static bool _watchAppInstalledCache = false; static DateTime? _lastWatchInstallCheckAt; static const Duration _watchInstallCheckCooldown = Duration(seconds: 10); static const Duration _tokenUsableSkew = Duration(seconds: 60); + static const Duration _leasePollInterval = Duration(milliseconds: 250); + static const Duration _iPhoneRefreshLeaseTtl = Duration(seconds: 120); + static const Duration _watchRefreshLeaseMaxWait = Duration(seconds: 150); static const String _iosFreshInstallHandledKey = 'ios_fresh_install_cleanup_done_v1'; @@ -258,6 +262,21 @@ class WatchSyncHelper { return _watchAppInstalledCache; } + static Future isWatchReachable({bool forceRefreshInstall = false}) async { + if (!Platform.isIOS) return false; + + final watchInstalled = + await isWatchAppInstalled(forceRefresh: forceRefreshInstall); + if (!watchInstalled) return false; + + final result = await _invokeMethodWithTimeout( + 'isWatchReachable', + null, + const Duration(seconds: 2), + ); + return result == true; + } + static Future clearICloudToken({bool notifyWatch = false}) async { if (!Platform.isIOS) return; await _invokeMethodWithTimeout( @@ -281,6 +300,124 @@ class WatchSyncHelper { ); } + static Future waitForWatchRefreshLease({ + required int studentIdNorm, + Duration maxWait = _watchRefreshLeaseMaxWait, + }) async { + if (!Platform.isIOS) return true; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) return true; + + final timeout = maxWait + const Duration(seconds: 5); + final result = await _invokeMethodWithTimeout( + 'waitForPeerRefreshLease', + { + 'owner': _leaseOwnerIPhone, + 'studentIdNorm': studentIdNorm, + 'maxWaitMs': maxWait.inMilliseconds, + 'pollIntervalMs': _leasePollInterval.inMilliseconds, + }, + timeout, + ); + + if (result is! Map) { + debugPrint('[WatchSync] Lease wait returned invalid response: $result'); + return true; + } + + final ready = result['ready'] == true; + final status = result['status']; + final waitedMs = result['waitedMs']; + final leaseChanged = result['leaseChanged'] == true; + debugPrint( + '[WatchSync] Lease wait status=$status ready=$ready waitedMs=$waitedMs leaseChanged=$leaseChanged'); + return ready; + } + + static Future acquireIPhoneRefreshLease({ + required int studentIdNorm, + Duration ttl = _iPhoneRefreshLeaseTtl, + }) async { + if (!Platform.isIOS) return null; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) return null; + + final result = await _invokeMethodWithTimeout( + 'acquireRefreshLease', + { + 'owner': _leaseOwnerIPhone, + 'studentIdNorm': studentIdNorm, + 'ttlMs': ttl.inMilliseconds, + }, + const Duration(seconds: 5), + ); + + if (result is! Map) { + debugPrint('[WatchSync] Lease acquire returned invalid response: $result'); + return null; + } + if (result['skipped'] == true) { + return null; + } + final operationId = result['operationId'] as String?; + if (operationId == null || operationId.isEmpty) { + debugPrint('[WatchSync] Lease acquire response missing operationId'); + return null; + } + return operationId; + } + + static Future releaseIPhoneRefreshLease({ + required int studentIdNorm, + required String operationId, + }) async { + if (!Platform.isIOS) return; + await _invokeMethodWithTimeout( + 'releaseRefreshLease', + { + 'owner': _leaseOwnerIPhone, + 'studentIdNorm': studentIdNorm, + 'operationId': operationId, + }, + const Duration(seconds: 5), + ); + } + + static Future clearRefreshLeaseForAccount(int studentIdNorm) async { + if (!Platform.isIOS) return; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) return; + await _invokeMethodWithTimeout( + 'clearRefreshLeaseForAccount', + { + 'studentIdNorm': studentIdNorm, + }, + const Duration(seconds: 5), + ); + } + + static Future clearAllRefreshLeases() async { + if (!Platform.isIOS) return; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) return; + await _invokeMethodWithTimeout( + 'clearAllRefreshLeases', + null, + const Duration(seconds: 5), + ); + } + + static Future clearSharedLanguageState() async { + if (!Platform.isIOS) return; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) return; + await _invokeMethodWithTimeout( + 'clearSharedLanguageState', + null, + const Duration(seconds: 5), + ); + } + static Future runFreshInstallCleanupIfNeeded({ required Isar isar, }) async { @@ -295,6 +432,7 @@ class WatchSyncHelper { debugPrint( '[WatchSync] Fresh iOS install detected, clearing iCloud and local auth state'); await clearICloudToken(notifyWatch: true); + await clearAllRefreshLeases(); await isar.writeTxn(() async { await isar.tokenModels.clear(); @@ -391,17 +529,16 @@ class WatchSyncHelper { return {'error': 'token_incomplete'}; } - if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) { - debugPrint( - '[WatchSync] Active iPhone token is expired, not sending to Watch'); - return {'error': 'needsReauth'}; - } - if (KretaClient.needsReauth) { debugPrint('[WatchSync] iPhone needs reauth'); return {'error': 'needsReauth'}; } + if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) { + debugPrint( + '[WatchSync] Active iPhone token access is expired, forwarding token to Watch for recovery'); + } + final tokenData = _buildTokenSyncPayload(token, includeSentAt: true); debugPrint('[WatchSync] Returning token for Watch'); @@ -410,6 +547,8 @@ class WatchSyncHelper { static Future sendTokenToWatch() async { if (!Platform.isIOS) return; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) return; final tokenData = _getTokenForWatch(); if (tokenData == null) return; @@ -420,9 +559,15 @@ class WatchSyncHelper { /// Sends a specific token directly to Watch. /// Useful during app initialization before global init state is fully ready. - static Future sendTokenModelToWatch(TokenModel token) async { + static Future sendTokenModelToWatch( + TokenModel token, { + bool allowExpiredAccessToken = false, + }) async { if (!Platform.isIOS) return; - await _sendTokenToWatchInternal(token); + await _sendTokenToWatchInternal( + token, + allowExpiredAccessToken: allowExpiredAccessToken, + ); } static Future> _processTokenFromWatch( @@ -522,8 +667,16 @@ class WatchSyncHelper { } } - static Future _sendTokenToWatchInternal(TokenModel token) async { + static Future _sendTokenToWatchInternal( + TokenModel token, { + bool allowExpiredAccessToken = false, + }) async { if (!Platform.isIOS) return; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) { + debugPrint('[WatchSync] No paired Watch app, skipping token send'); + return; + } if (token.accessToken == null || token.refreshToken == null || @@ -532,10 +685,16 @@ class WatchSyncHelper { return; } - if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) { + final accessExpired = + !_isAccessTokenUsable(token.expiryDate, skew: const Duration()); + if (accessExpired && !allowExpiredAccessToken) { debugPrint('[WatchSync] Token expired, not sending to Watch'); return; } + if (accessExpired && allowExpiredAccessToken) { + debugPrint( + '[WatchSync] Sending expired-access token to Watch for account-switch recovery'); + } final tokenData = _buildTokenSyncPayload(token, includeSentAt: true); @@ -556,6 +715,11 @@ class WatchSyncHelper { static Future sendLanguageToWatch() async { if (!Platform.isIOS) return; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) { + debugPrint('[WatchSync] No paired Watch app, skipping language publish'); + return; + } final languageCode = _getLanguageForWatch(); if (languageCode == null) return; @@ -575,6 +739,10 @@ class WatchSyncHelper { bool allowExpiredAccessToken = false, }) async { if (!Platform.isIOS) return false; + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) { + return false; + } final effectiveIsar = isar ?? (initDone ? initData.isar : null); final effectiveTokens = tokens ?? (initDone ? initData.tokens : null); @@ -705,7 +873,10 @@ class WatchSyncHelper { } /// Save token to iCloud. Call this after refreshing token on iPhone. - static Future saveTokenToiCloud(TokenModel token) async { + static Future saveTokenToiCloud( + TokenModel token, { + bool forceAccountSwitch = false, + }) async { if (!Platform.isIOS) return; if (token.accessToken == null || @@ -723,6 +894,7 @@ class WatchSyncHelper { } final tokenData = _buildTokenSyncPayload(token); + tokenData['forceAccountSwitch'] = forceAccountSwitch; await _invokeMethodWithTimeout( 'saveTokeToniCloud', tokenData, const Duration(seconds: 5)); @@ -766,7 +938,10 @@ class WatchSyncHelper { currentToken.expiryDate != null && !KretaClient.needsReauth) { debugPrint('[WatchSync] Sending iPhone token to Watch (no response)'); - await _sendTokenToWatchInternal(currentToken); + await _sendTokenToWatchInternal( + currentToken, + allowExpiredAccessToken: true, + ); } return; } @@ -781,7 +956,10 @@ class WatchSyncHelper { !KretaClient.needsReauth) { debugPrint( '[WatchSync] Sending iPhone token to Watch (Watch has no token)'); - await _sendTokenToWatchInternal(currentToken); + await _sendTokenToWatchInternal( + currentToken, + allowExpiredAccessToken: true, + ); } return; } @@ -807,7 +985,10 @@ class WatchSyncHelper { currentToken.refreshToken != null && currentToken.expiryDate != null && !KretaClient.needsReauth) { - await _sendTokenToWatchInternal(currentToken); + await _sendTokenToWatchInternal( + currentToken, + allowExpiredAccessToken: true, + ); } return; } @@ -823,7 +1004,10 @@ class WatchSyncHelper { _isAccessTokenUsable(currentToken.expiryDate, skew: const Duration()) && !KretaClient.needsReauth) { - await _sendTokenToWatchInternal(currentToken); + await _sendTokenToWatchInternal( + currentToken, + allowExpiredAccessToken: true, + ); } return; } @@ -882,7 +1066,10 @@ class WatchSyncHelper { } else { debugPrint( '[WatchSync] iPhone token is same or newer, sending to Watch'); - await _sendTokenToWatchInternal(currentToken); + await _sendTokenToWatchInternal( + currentToken, + allowExpiredAccessToken: true, + ); } } catch (e) { debugPrint('[WatchSync] Failed to sync token from Watch: $e'); diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 047a4314..5410dc15 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -265,6 +265,12 @@ Future _initData(AppInitialization init) async { } unawaited(() async { + try { + await WatchSyncHelper.saveTokenToiCloud(token); + } catch (e) { + logger.warning('[Init] Failed to sync active token to iCloud: $e'); + } + try { await WatchSyncHelper.sendTokenModelToWatch(token); } catch (e) { diff --git a/firka/lib/ui/phone/screens/settings/settings_screen.dart b/firka/lib/ui/phone/screens/settings/settings_screen.dart index 5cc7ae3d..8bead5a3 100644 --- a/firka/lib/ui/phone/screens/settings/settings_screen.dart +++ b/firka/lib/ui/phone/screens/settings/settings_screen.dart @@ -662,8 +662,24 @@ class _SettingsScreenState extends FirkaState { ), onTap: () async { if (i != item.accountIndex) { + final previousAccountId = widget.data.client.model.studentIdNorm; if (Platform.isIOS) { await LiveActivityService.onUserLogout(); + try { + await WatchSyncHelper.clearSharedLanguageState(); + } catch (e) { + logger.warning( + '[Settings] Failed to clear shared language state on account switch: $e'); + } + if (previousAccountId != null) { + try { + await WatchSyncHelper.clearRefreshLeaseForAccount( + previousAccountId); + } catch (e) { + logger.warning( + '[Settings] Failed to clear refresh lease on account switch: $e'); + } + } } await widget.data.isar.writeTxn(() async { @@ -674,6 +690,40 @@ class _SettingsScreenState extends FirkaState { await item.postUpdate(); + if (Platform.isIOS) { + var watchReachable = false; + try { + watchReachable = await WatchSyncHelper.isWatchReachable( + forceRefreshInstall: true, + ); + } catch (e) { + logger.warning( + '[Settings] Failed to query Watch reachability on account switch: $e'); + } + + if (watchReachable) { + try { + await WatchSyncHelper.sendTokenModelToWatch( + token, + allowExpiredAccessToken: true, + ); + } catch (e) { + logger.warning( + '[Settings] Failed to send switched account token to reachable Watch: $e'); + } + } else { + try { + await WatchSyncHelper.saveTokenToiCloud( + token, + forceAccountSwitch: true, + ); + } catch (e) { + logger.warning( + '[Settings] Failed to sync switched account token to iCloud: $e'); + } + } + } + runApp(InitializationScreen()); } }, @@ -725,6 +775,20 @@ class _SettingsScreenState extends FirkaState { } final active = widget.data.client.model.studentIdNorm!; + if (Platform.isIOS) { + try { + await WatchSyncHelper.clearRefreshLeaseForAccount(active); + } catch (e) { + logger.warning( + '[Settings] Failed to clear refresh lease for active account: $e'); + } + try { + await WatchSyncHelper.clearSharedLanguageState(); + } catch (e) { + logger.warning( + '[Settings] Failed to clear shared language state on logout: $e'); + } + } await widget.data.isar.writeTxn(() async { await widget.data.isar.tokenModels.delete(active); @@ -740,6 +804,7 @@ class _SettingsScreenState extends FirkaState { if (Platform.isIOS) { try { await WatchSyncHelper.clearICloudToken(notifyWatch: true); + await WatchSyncHelper.clearAllRefreshLeases(); } catch (e) { logger.warning('[Settings] Failed to clear iCloud token: $e'); } @@ -752,6 +817,41 @@ class _SettingsScreenState extends FirkaState { (route) => false, ); } else { + if (Platform.isIOS) { + final nextToken = accounts.first; + var watchReachable = false; + try { + watchReachable = await WatchSyncHelper.isWatchReachable( + forceRefreshInstall: true, + ); + } catch (e) { + logger.warning( + '[Settings] Failed to query Watch reachability after logout: $e'); + } + + if (watchReachable) { + try { + await WatchSyncHelper.sendTokenModelToWatch( + nextToken, + allowExpiredAccessToken: true, + ); + } catch (e) { + logger.warning( + '[Settings] Failed to send next account token to reachable Watch after logout: $e'); + } + } else { + try { + await WatchSyncHelper.saveTokenToiCloud( + nextToken, + forceAccountSwitch: true, + ); + } catch (e) { + logger.warning( + '[Settings] Failed to sync next account token to iCloud after logout: $e'); + } + } + } + widget.data.tokens = accounts; runApp(InitializationScreen()); }