From c646ea2d51294722e4403ddeb5d480f148ec6a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Fri, 27 Feb 2026 22:44:53 +0100 Subject: [PATCH] Improve Watch sync, token, and live activity handling Multiple fixes and improvements for watch <> phone sync, token recovery, and live activity behavior: - WatchSessionManager: add mergeApplicationContext to avoid clobbering app context, add thread-safe pending auth queue and flush, add sendMessageToWatch API, ensure message handling runs on main thread, add reply-timeout logic for language requests, support fire-and-forget messages, and improve enqueue/flush logic. - WatchConnectivityManager & SettingsView: publish shared session state on force-logout/logout and improve account-switch/token handling; clear DataStore error and reset recovery state after token updates. - DataStore & WatchL10n: add recovery-in-progress guard to avoid duplicate recovery runs, reset language version tracking on account switch, make WatchL10n.setLanguage main-thread safe. - TokenManager & SharedKeychainManager: remove old keychain observer plumbing and instead publish shared session state when active token changes or is deleted. - UI tweaks: reduce icon/text sizes and spacing in pairing view; only show sync button when paired; Settings logout now also publishes shared state. - Watch sync wiring in Flutter: replace direct watch_connectivity usage with a MethodChannel-backed WatchSyncHelper.sendMessageToWatch and onWatchMessage callback; main and pairing UI updated accordingly. - Kreta client: replace simple boolean mutex with a Completer-based mutex and timeout handling to avoid busy-waiting. - LiveActivityService: throttle/avoid frequent activity recreation (cache last recreation time), skip placeholder creation when called from background, and minor cache-clearing adjustments. - HomeScreen: add WidgetsBindingObserver to manage lifecycle, prevent prefetch while backgrounded, debounce prefetch, ensure LiveActivity registration runs once and refresh on resume after first prefetch. These changes increase robustness of token sync and account switching, reduce race conditions and duplicate work, and avoid conflicts between WCSession and Flutter plugin delegates. --- .../FirkaWatch Watch App/ContentView.swift | 20 +- .../Localization/WatchL10n.swift | 19 +- .../Services/DataStore.swift | 12 +- .../Services/WatchConnectivityManager.swift | 21 +- .../Views/SettingsView.swift | 4 + firka/ios/Runner/WatchSessionManager.swift | 221 +++++++++++------- .../Shared/API/SharedKeychainManager.swift | 11 - firka/ios/Shared/API/TokenManager.swift | 69 ++---- .../lib/helpers/api/client/kreta_client.dart | 30 ++- firka/lib/helpers/live_activity_service.dart | 29 ++- firka/lib/helpers/watch_sync_helper.dart | 33 +++ firka/lib/main.dart | 14 +- .../ui/phone/pages/extras/main_wear_pair.dart | 6 +- .../ui/phone/screens/home/home_screen.dart | 98 ++++++-- 14 files changed, 377 insertions(+), 210 deletions(-) diff --git a/firka/ios/FirkaWatch Watch App/ContentView.swift b/firka/ios/FirkaWatch Watch App/ContentView.swift index 9308dc5a..919c8400 100644 --- a/firka/ios/FirkaWatch Watch App/ContentView.swift +++ b/firka/ios/FirkaWatch Watch App/ContentView.swift @@ -72,6 +72,18 @@ struct ContentView: View { guard scenePhase == .active else { return } dataStore.reconcileSharedSessionState() WatchL10n.shared.reconcileFromSharedState() + + if !dataStore.hasToken { + dataStore.checkTokenState() + if dataStore.hasToken { + print("[Watch] Token appeared (iCloud Keychain sync?), refreshing...") + Task { + await dataStore.refreshAllWithRecovery() + } + } + return + } + if shouldAutoRefresh && !dataStore.isLoading { print("[Watch] Data became stale (>10 min), auto-refreshing...") Task { @@ -151,21 +163,21 @@ struct PairingView: View { } var body: some View { - VStack(spacing: 16) { + VStack(spacing: 10) { Image(systemName: iconName) - .font(.system(size: 50)) + .font(.system(size: 36)) .foregroundColor(.blue) Text(titleKey.localized) .font(.headline) Text(descriptionKey.localized) - .font(.caption) + .font(.caption2) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) - if isWatchSystemPaired && WCSession.default.isReachable { + if isWatchSystemPaired { 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 2784cf64..5d7be8f7 100644 --- a/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift +++ b/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift @@ -74,9 +74,17 @@ class WatchL10n { } func setLanguage(_ language: WatchLanguage) { - currentLanguage = language - loadStrings() - WidgetCenter.shared.reloadAllTimelines() + if Thread.isMainThread { + currentLanguage = language + loadStrings() + WidgetCenter.shared.reloadAllTimelines() + } else { + DispatchQueue.main.async { [self] in + currentLanguage = language + loadStrings() + WidgetCenter.shared.reloadAllTimelines() + } + } } func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) { @@ -115,6 +123,11 @@ class WatchL10n { UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey) } + func resetLanguageVersionTracking() { + setLastAppliedSharedLanguageVersion(0) + print("[WatchL10n] Language version tracking reset for account switch") + } + func reconcileFromSharedState() { guard syncWithiPhone else { return } guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return } diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index c4007e2c..f0ffe216 100644 --- a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -106,6 +106,7 @@ class DataStore { lastUpdated = nil error = nil recoveryAttempted = false + WatchL10n.shared.resetLanguageVersionTracking() } setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm) } else { @@ -322,7 +323,16 @@ class DataStore { } } + private var isRecoveryInProgress: Bool = false + func refreshAllWithRecovery() async { + guard !isRecoveryInProgress && !isLoading else { + print("[Watch] refreshAllWithRecovery() already in progress or refreshAll() running, skipping duplicate call") + return + } + isRecoveryInProgress = true + defer { isRecoveryInProgress = false } + reconcileSharedSessionState() WatchL10n.shared.refreshFromiPhoneAndSharedState() @@ -334,7 +344,7 @@ class DataStore { if shouldRequestTokenFromPhone { WatchConnectivityManager.shared.requestTokenFromPhone() - try? await Task.sleep(nanoseconds: 700_000_000) + try? await Task.sleep(nanoseconds: 2_000_000_000) checkTokenState() } diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift index 7a2b2d18..ce5175be 100644 --- a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift +++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift @@ -294,6 +294,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { private func handleForceLogoutFromPhone() { TokenManager.shared.deleteToken() + _ = SharedSessionStateManager.shared.publishState( + hasAnyAccount: false, + activeStudentIdNorm: nil + ) DataStore.shared.clearAll() DataStore.shared.resetRecoveryState() DataStore.shared.checkTokenState() @@ -400,19 +404,24 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { let token = try decoder.decode(WatchToken.self, from: jsonData) let currentToken = TokenManager.shared.loadToken() + + let isAccountSwitch = currentToken != nil && !token.isSameAccount(as: currentToken!) let shouldForceAccountSwitch: Bool - if incomingSentAtMs > 0, - let currentToken, - !token.isSameAccount(as: currentToken) { - shouldForceAccountSwitch = true + if isAccountSwitch { + if incomingSentAtMs > 0 { + shouldForceAccountSwitch = true + } else { + shouldForceAccountSwitch = token.isNewer(than: currentToken!) + } } else { shouldForceAccountSwitch = false } if incomingSentAtMs <= 0, let currentToken, + !isAccountSwitch, !token.isNewer(than: currentToken) { - print("[Watch] Ignoring stale token_update without sentAtMs") + print("[Watch] Ignoring stale token_update without sentAtMs (same account, not newer)") return } @@ -432,6 +441,8 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs) } + DataStore.shared.clearError() + DataStore.shared.resetRecoveryState() DataStore.shared.checkTokenState() Task { diff --git a/firka/ios/FirkaWatch Watch App/Views/SettingsView.swift b/firka/ios/FirkaWatch Watch App/Views/SettingsView.swift index 3820cbcb..9e498d25 100644 --- a/firka/ios/FirkaWatch Watch App/Views/SettingsView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/SettingsView.swift @@ -69,6 +69,10 @@ struct SettingsView: View { private func logout() { TokenManager.shared.deleteToken() + _ = SharedSessionStateManager.shared.publishState( + hasAnyAccount: false, + activeStudentIdNorm: nil + ) DataStore.shared.clearAll() } } diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift index be35d628..786bf8cd 100644 --- a/firka/ios/Runner/WatchSessionManager.swift +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -9,11 +9,30 @@ class WatchSessionManager: NSObject, WCSessionDelegate { private var isFlutterWatchSyncReady = false private var pendingAuthPayloads: [[String: Any]] = [] private var pendingICloudRecoveryNotification = false + private let pendingAuthQueue = DispatchQueue(label: "app.firka.pendingAuthQueue") override private init() { super.init() } + private func mergeApplicationContext(_ newEntries: [String: Any]) { + let session = WCSession.default + guard session.activationState == .activated else { return } + + var merged = session.applicationContext + for (key, value) in newEntries { + merged[key] = value + } + if !newEntries.keys.contains("force_logout") { + merged.removeValue(forKey: "force_logout") + } + do { + try session.updateApplicationContext(merged) + } catch { + print("[WatchSessionManager] Failed to merge applicationContext: \(error)") + } + } + func setup(with messenger: FlutterBinaryMessenger) { flutterChannel = FlutterMethodChannel( name: "app.firka/watch_sync", @@ -58,6 +77,8 @@ class WatchSessionManager: NSObject, WCSessionDelegate { self?.handleClearAllRefreshLeases(result: result) case "clearSharedLanguageState": self?.handleClearSharedLanguageState(result: result) + case "sendMessageToWatch": + self?.handleSendMessageToWatch(arguments: call.arguments, result: result) default: result(FlutterMethodNotImplemented) } @@ -160,11 +181,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate { } private func enqueuePendingAuth(_ authData: [String: Any]) { - if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) { - return + pendingAuthQueue.sync { + if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) { + return + } + pendingAuthPayloads.append(authData) + print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready") } - pendingAuthPayloads.append(authData) - print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready") } private func forwardTokenToFlutter(_ authData: [String: Any]) { @@ -188,13 +211,19 @@ class WatchSessionManager: NSObject, WCSessionDelegate { guard isFlutterWatchSyncReady else { return } - if !pendingAuthPayloads.isEmpty { - print("[WatchSessionManager] Flushing \(pendingAuthPayloads.count) queued token event(s) to Flutter") + + let payloadsToFlush: [[String: Any]] = pendingAuthQueue.sync { + let copy = pendingAuthPayloads + pendingAuthPayloads.removeAll() + return copy } - for authData in pendingAuthPayloads { + + if !payloadsToFlush.isEmpty { + print("[WatchSessionManager] Flushing \(payloadsToFlush.count) queued token event(s) to Flutter") + } + for authData in payloadsToFlush { flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) } - pendingAuthPayloads.removeAll() if pendingICloudRecoveryNotification { pendingICloudRecoveryNotification = false @@ -228,13 +257,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { let session = WCSession.default - do { - try session.updateApplicationContext([ - "auth": authData - ]) - } catch { - print("[WatchSessionManager] Failed to update applicationContext for token: \(error)") - } + mergeApplicationContext(["auth": authData]) session.transferUserInfo([ "id": "token_update", @@ -271,13 +294,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return } - do { - try WCSession.default.updateApplicationContext(["widget_data": jsonString]) - result(nil) - print("[WatchSessionManager] Widget data sent to Watch") - } catch { - result(FlutterError(code: "UPDATE_ERROR", message: error.localizedDescription, details: nil)) - } + mergeApplicationContext(["widget_data": jsonString]) + result(nil) + print("[WatchSessionManager] Widget data sent to Watch") } private func handleSendLanguageToWatch(arguments: Any?, result: @escaping FlutterResult) { @@ -295,15 +314,11 @@ class WatchSessionManager: NSObject, WCSessionDelegate { let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: 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)") - } + mergeApplicationContext([ + "language": languageCode, + "language_state_version": sharedState.stateVersion + ]) + print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext") WCSession.default.transferUserInfo([ "id": "language_update", @@ -601,11 +616,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return } - do { - try WCSession.default.updateApplicationContext(["force_logout": true]) - } catch { - print("[WatchSessionManager] Failed to update applicationContext for logout: \(error)") - } + mergeApplicationContext(["force_logout": true]) WCSession.default.transferUserInfo([ "id": "force_logout" @@ -614,6 +625,28 @@ class WatchSessionManager: NSObject, WCSessionDelegate { result(nil) } + private func handleSendMessageToWatch(arguments: Any?, result: @escaping FlutterResult) { + guard let message = arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil)) + return + } + + guard WCSession.default.activationState == .activated else { + result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil)) + return + } + + guard WCSession.default.isReachable else { + result(FlutterError(code: "NOT_REACHABLE", message: "Watch is not reachable", details: nil)) + return + } + + WCSession.default.sendMessage(message, replyHandler: nil, errorHandler: { error in + print("[WatchSessionManager] Failed to send message to Watch: \(error.localizedDescription)") + }) + result(nil) + } + func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, @@ -651,6 +684,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate { didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void ) { + DispatchQueue.main.async { [self] in + self._handleMessageWithReply(message: message, replyHandler: replyHandler) + } + } + + private func _handleMessageWithReply(message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { print("[WatchSessionManager] Received message from Watch: \(message)") guard let action = message["action"] as? String else { @@ -670,8 +709,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate { } return } - DispatchQueue.main.async { - self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in + self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in if let tokenData = result as? [String: Any] { if let error = tokenData["error"] as? String { if error == "needsReauth" { @@ -703,7 +741,6 @@ class WatchSessionManager: NSObject, WCSessionDelegate { } } } - } case "requestLanguage": if !self.isFlutterWatchSyncReady { @@ -734,37 +771,56 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return } - DispatchQueue.main.async { - flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in - if let languageCode = result as? String, !languageCode.isEmpty { - if let existingState = SharedLanguageStateManager.shared.loadState(), - existingState.languageCode == languageCode { - print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)") - replyHandler([ - "language": languageCode, - "language_state_version": existingState.stateVersion - ]) - return - } + var hasReplied = false + let timeoutWorkItem = DispatchWorkItem { + guard !hasReplied else { return } + hasReplied = true + if let sharedState = SharedLanguageStateManager.shared.loadState() { + print("[WatchSessionManager] Flutter timeout, serving shared language: \(sharedState.languageCode)") + replyHandler([ + "language": sharedState.languageCode, + "language_state_version": sharedState.stateVersion + ]) + } else { + print("[WatchSessionManager] Flutter timeout and no shared language available") + replyHandler(["error": "timeout"]) + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWorkItem) - let sharedState = SharedLanguageStateManager.shared.publishState( - languageCode: languageCode - ) - print("[WatchSessionManager] Sending language to Watch: \(languageCode)") + flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in + timeoutWorkItem.cancel() + guard !hasReplied else { return } + hasReplied = true + + if let languageCode = result as? String, !languageCode.isEmpty { + if let existingState = SharedLanguageStateManager.shared.loadState(), + existingState.languageCode == languageCode { + print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)") replyHandler([ "language": languageCode, - "language_state_version": sharedState.stateVersion + "language_state_version": existingState.stateVersion ]) - } else if let sharedState = SharedLanguageStateManager.shared.loadState() { - print("[WatchSessionManager] No language from Flutter, serving last shared language: \(sharedState.languageCode)") - replyHandler([ - "language": sharedState.languageCode, - "language_state_version": sharedState.stateVersion - ]) - } else { - print("[WatchSessionManager] No language available from Flutter or shared state") - replyHandler(["error": "language_not_ready"]) + return } + + let sharedState = SharedLanguageStateManager.shared.publishState( + languageCode: languageCode + ) + print("[WatchSessionManager] Sending language to Watch: \(languageCode)") + replyHandler([ + "language": languageCode, + "language_state_version": sharedState.stateVersion + ]) + } else if let sharedState = SharedLanguageStateManager.shared.loadState() { + print("[WatchSessionManager] No language from Flutter, serving last shared language: \(sharedState.languageCode)") + replyHandler([ + "language": sharedState.languageCode, + "language_state_version": sharedState.stateVersion + ]) + } else { + print("[WatchSessionManager] No language available from Flutter or shared state") + replyHandler(["error": "language_not_ready"]) } } @@ -776,27 +832,23 @@ class WatchSessionManager: NSObject, WCSessionDelegate { if !self.isFlutterWatchSyncReady { print("[WatchSessionManager] Flutter not ready, queueing token from Watch") - DispatchQueue.main.async { - self.enqueuePendingAuth(tokenData) - } + self.enqueuePendingAuth(tokenData) replyHandler(["success": true]) return } print("[WatchSessionManager] Receiving token from Watch") - DispatchQueue.main.async { - self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in - if let success = result as? Bool, success { - print("[WatchSessionManager] Flutter accepted Watch token") - replyHandler(["success": true]) - } else if let resultDict = result as? [String: Any], - let success = resultDict["success"] as? Bool, success { - print("[WatchSessionManager] Flutter accepted Watch token") - replyHandler(["success": true]) - } else { - print("[WatchSessionManager] Flutter rejected Watch token") - replyHandler(["error": "rejected"]) - } + self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in + if let success = result as? Bool, success { + print("[WatchSessionManager] Flutter accepted Watch token") + replyHandler(["success": true]) + } else if let resultDict = result as? [String: Any], + let success = resultDict["success"] as? Bool, success { + print("[WatchSessionManager] Flutter accepted Watch token") + replyHandler(["success": true]) + } else { + print("[WatchSessionManager] Flutter rejected Watch token") + replyHandler(["error": "rejected"]) } } @@ -805,6 +857,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate { } } + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + print("[WatchSessionManager] Received fire-and-forget message from Watch: \(message)") + DispatchQueue.main.async { + self.flutterChannel?.invokeMethod("onWatchMessage", arguments: message) + } + } + func sessionDidBecomeInactive(_ session: WCSession) { print("[WatchSessionManager] Session did become inactive") } diff --git a/firka/ios/Shared/API/SharedKeychainManager.swift b/firka/ios/Shared/API/SharedKeychainManager.swift index 4b156072..5e145079 100644 --- a/firka/ios/Shared/API/SharedKeychainManager.swift +++ b/firka/ios/Shared/API/SharedKeychainManager.swift @@ -16,8 +16,6 @@ class SharedKeychainManager { private let deviceName = "Watch" #endif - private var changeObserver: ((WatchToken) -> Void)? - private init() {} var resolvedAccessGroup: String { @@ -201,15 +199,6 @@ class SharedKeychainManager { } } - // MARK: - Observer (for compatibility with old iCloudTokenManager interface) - func observeChanges(_ observer: @escaping (WatchToken) -> Void) { - self.changeObserver = observer - } - - func notifyObservers(with token: WatchToken) { - changeObserver?(token) - } - // MARK: - Migration from KV Store func migrateFromKVStoreAndClear() -> WatchToken? { let iCloudStore = NSUbiquitousKeyValueStore.default diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift index b357c513..90f9d4f4 100644 --- a/firka/ios/Shared/API/TokenManager.swift +++ b/firka/ios/Shared/API/TokenManager.swift @@ -186,61 +186,6 @@ class TokenManager { private init() { runKVStoreMigrationIfNeeded() - - SharedKeychainManager.shared.observeChanges { [weak self] sharedToken in - guard let self = self else { return } - - let preferredStudentIdNorm = self.getActiveStudentIdNorm() - let isValidToken = sharedToken.expiryDate > Date().addingTimeInterval(60) - let preferredLocalToken = self.localTokenFromKeychainAndFile( - preferredStudentIdNorm: preferredStudentIdNorm - ) - - if let preferredStudentIdNorm, - sharedToken.studentIdNorm != preferredStudentIdNorm, - preferredLocalToken != nil { - print("[TokenManager] Ignoring shared Keychain token for inactive account (\(sharedToken.studentIdNorm)), active is \(preferredStudentIdNorm)") - return - } - - let localToken = preferredLocalToken ?? self.localTokenFromKeychainAndFile() - - if let localToken = localToken { - if sharedToken.isNewer(than: localToken) { - print("[TokenManager] Shared Keychain token is fresher, updating local cache") - try? self.saveTokenToKeychain(sharedToken) - try? self.saveTokenToFile(sharedToken) - self.setActiveStudentIdNorm(sharedToken.studentIdNorm) - - #if os(watchOS) - DataStore.shared.checkTokenState() - #endif - - #if os(iOS) - if isValidToken { - self.notifyiOSTokenRecovered() - } - #endif - } else { - print("[TokenManager] Local token is fresher or equal, ignoring shared Keychain update") - } - } else { - print("[TokenManager] No local token, using shared Keychain token") - try? self.saveTokenToKeychain(sharedToken) - try? self.saveTokenToFile(sharedToken) - self.setActiveStudentIdNorm(sharedToken.studentIdNorm) - - #if os(watchOS) - DataStore.shared.checkTokenState() - #endif - - #if os(iOS) - if isValidToken { - self.notifyiOSTokenRecovered() - } - #endif - } - } } private let kvStoreMigrationKey = "firka_kv_store_migrated_v1" @@ -350,8 +295,19 @@ class TokenManager { candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest } } + let previousActiveStudentIdNorm = getActiveStudentIdNorm() setActiveStudentIdNorm(freshest.token.studentIdNorm) + #if os(iOS) + if previousActiveStudentIdNorm != freshest.token.studentIdNorm { + _ = SharedSessionStateManager.shared.publishState( + hasAnyAccount: true, + activeStudentIdNorm: freshest.token.studentIdNorm + ) + print("[TokenManager] Active account changed from \(previousActiveStudentIdNorm ?? 0) to \(freshest.token.studentIdNorm), published to SharedSessionState") + } + #endif + let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" formatter.timeZone = TimeZone.current @@ -393,6 +349,9 @@ class TokenManager { // MARK: - Delete Token func deleteToken() { print("[TokenManager] Deleting token from all storage locations") + + SharedSessionStateManager.shared.publishState(hasAnyAccount: false, activeStudentIdNorm: nil) + if let previousToken = loadToken() { RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm) } else { diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index 98aa8ff2..5c8938af 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; @@ -61,7 +62,7 @@ class ApiResponse { } class KretaClient { - bool _tokenMutex = false; + Completer? _tokenMutexCompleter; TokenModel model; Isar isar; @@ -321,22 +322,29 @@ class KretaClient { Future _mutexCallback(Future Function() callback) async { const maxWaitTime = Duration(seconds: 30); - final startTime = DateTime.now(); - while (_tokenMutex) { - if (DateTime.now().difference(startTime) > maxWaitTime) { - logger.warning( - "[Mutex] Timeout waiting for token mutex, forcing release"); - _tokenMutex = false; - break; + if (_tokenMutexCompleter != null) { + try { + await _tokenMutexCompleter!.future.timeout(maxWaitTime, onTimeout: () { + logger.warning( + "[Mutex] Timeout waiting for token mutex, forcing release"); + if (_tokenMutexCompleter != null && !_tokenMutexCompleter!.isCompleted) { + _tokenMutexCompleter!.complete(); + } + }); + } catch (_) { } - await Future.delayed(const Duration(milliseconds: 50)); } - _tokenMutex = true; + + _tokenMutexCompleter = Completer(); try { return await callback(); } finally { - _tokenMutex = false; + final completer = _tokenMutexCompleter; + _tokenMutexCompleter = null; + if (completer != null && !completer.isCompleted) { + completer.complete(); + } } } diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart index 94952bf9..783c109d 100644 --- a/firka/lib/helpers/live_activity_service.dart +++ b/firka/lib/helpers/live_activity_service.dart @@ -30,6 +30,7 @@ class LiveActivityService { static Timer? _updateTimer; static String? _cachedDeviceToken; static bool _isInitialized = false; + static DateTime? _lastActivityRecreation; static Timer? _bellDelayDebounceTimer; static double? _pendingBellDelay; @@ -733,6 +734,7 @@ class LiveActivityService { return; } + final liveActivityEnabled = await isEnabled(settingsStore, client); final morningNotificationEnabled = _getCurrentMorningNotificationEnabled() ?? false; _logger.info('onUserLogin: liveActivityEnabled=$liveActivityEnabled, morningNotificationEnabled=$morningNotificationEnabled'); @@ -949,6 +951,7 @@ class LiveActivityService { if (liveActivityEnabled) { await _startPlaceholderActivity(allLessons, studentName); + _lastActivityRecreation = DateTime.now(); } await _startTimetableMonitoring( @@ -969,7 +972,6 @@ class LiveActivityService { } /// Called when app is opened - sends timetable to backend, backend handles updates - /// IMPORTANT: Recreates Live Activity on every app open to refresh the 8-hour push token static Future onAppOpened({ required KretaClient client, required String studentName, @@ -985,6 +987,20 @@ class LiveActivityService { return; } + final now = DateTime.now(); + if (_lastActivityRecreation != null) { + final timeSinceLastRecreation = now.difference(_lastActivityRecreation!); + if (timeSinceLastRecreation < const Duration(minutes: 5)) { + _logger.info('onAppOpened: Skipping activity recreation, last was ${timeSinceLastRecreation.inSeconds}s ago'); + await checkAndUpdateTimetable( + client: client, + studentName: studentName, + settingsStore: settingsStore + ); + return; + } + } + final activeActivities = await LiveActivityManager.getActiveActivities(); if (activeActivities.isNotEmpty) { _logger.info('Ending existing activity to refresh push token (8-hour expiration)'); @@ -992,7 +1008,6 @@ class LiveActivityService { await Future.delayed(const Duration(milliseconds: 500)); } - final now = DateTime.now(); final todayStart = DateTime(now.year, now.month, now.day); final startOfWeek = todayStart.subtract(Duration(days: now.weekday - 1)); final endOfWeek = startOfWeek.add(const Duration(days: 6)); @@ -1000,6 +1015,7 @@ class LiveActivityService { final allLessons = timetableResponse.response ?? []; await _startPlaceholderActivity(allLessons, studentName); + _lastActivityRecreation = now; _logger.info('New activity created with fresh push token'); @@ -1328,7 +1344,12 @@ class LiveActivityService { } /// Starts a minimal placeholder activity shell - backend will update with real data - static Future _startPlaceholderActivity(List allLessons, String studentName) async { + static Future _startPlaceholderActivity(List allLessons, String studentName, {bool isBackground = false}) async { + if (isBackground) { + _logger.info('_startPlaceholderActivity: Called from background context, skipping to preserve existing activity'); + return; + } + // Always end existing activities to ensure fresh token (8-hour expiration) final activeActivities = await LiveActivityManager.getActiveActivities(); if (activeActivities.isNotEmpty) { @@ -1432,10 +1453,8 @@ class LiveActivityService { static Future _clearCache() async { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_deviceTokenKey); await prefs.remove(_lastTimetableUpdateKey); await prefs.remove(_isRegisteredKey); - _cachedDeviceToken = null; } /// Try to get cached token or wait a short period until iOS provides it diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index 8e767a5a..7c930852 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -466,11 +466,37 @@ class WatchSyncHelper { '[WatchSync] Token recovered from iCloud notification received'); await _handleTokenRecoveredFromiCloud(); return null; + case 'onWatchMessage': + _handleWatchMessage(call.arguments); + return null; default: return null; } } + /// Callback for Watch pairing message events. + /// Set by main.dart to handle "ping" messages for Watch pairing flow. + static void Function(Map message)? onWatchMessage; + + static void _handleWatchMessage(dynamic arguments) { + if (arguments == null) return; + try { + final Map message; + if (arguments is Map) { + message = arguments; + } else if (arguments is Map) { + message = Map.from(arguments); + } else { + debugPrint('[WatchSync] onWatchMessage: unexpected type ${arguments.runtimeType}'); + return; + } + debugPrint('[WatchSync] Received Watch message: ${message["id"]}'); + onWatchMessage?.call(message); + } catch (e) { + debugPrint('[WatchSync] Error handling Watch message: $e'); + } + } + /// Called when iOS receives a fresh token from iCloud (e.g., Watch refreshed) /// This clears the reauth flag if it was set, since we now have a valid token static Future _handleTokenRecoveredFromiCloud() async { @@ -545,6 +571,13 @@ class WatchSyncHelper { return tokenData; } + /// Send a fire-and-forget message to Watch via WatchSessionManager. + /// Replaces direct watch_connectivity plugin usage to avoid WCSession delegate conflict. + static Future sendMessageToWatch(Map message) async { + if (!Platform.isIOS) return; + await _invokeMethodWithTimeout('sendMessageToWatch', message); + } + static Future sendTokenToWatch() async { if (!Platform.isIOS) return; final watchInstalled = await isWatchAppInstalled(); diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 7014bed1..977466f3 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -29,7 +29,6 @@ import 'package:logging/logging.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:watch_connectivity/watch_connectivity.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'helpers/db/models/homework_cache_model.dart'; import 'helpers/update_notifier.dart'; @@ -544,30 +543,27 @@ class InitializationScreen extends StatelessWidget { }()); } - var watch = WatchConnectivity(); - if (!initData.hasWatchListener) { initData.hasWatchListener = true; - watch.messageStream.listen((e) { - var msg = e.entries.toMap(); - + WatchSyncHelper.onWatchMessage = (msg) { logger.finest("WatchOS IPC [Watch -> Phone]: ${msg["id"]}"); switch (msg["id"]) { case "ping": if (initData.tokens.isNotEmpty) { logger.finest("WatchOS IPC [Phone -> Watch]: pong"); - watch.sendMessage({"id": "pong"}); + const watchChannel = MethodChannel('app.firka/watch_sync'); + watchChannel.invokeMethod('sendMessageToWatch', {"id": "pong"}); navigatorKey.currentState?.push( MaterialPageRoute( builder: (context) => HomeScreen(initData, true, - model: msg["model"] as String), + model: msg["model"] as String? ?? "unknown"), ), ); } } - }); + }; } if (snapshot.data!.tokens.isEmpty) { diff --git a/firka/lib/ui/phone/pages/extras/main_wear_pair.dart b/firka/lib/ui/phone/pages/extras/main_wear_pair.dart index bbc40b35..866a9254 100644 --- a/firka/lib/ui/phone/pages/extras/main_wear_pair.dart +++ b/firka/lib/ui/phone/pages/extras/main_wear_pair.dart @@ -1,15 +1,14 @@ import 'package:firka/helpers/debug_helper.dart'; import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/helpers/watch_sync_helper.dart'; import 'package:firka/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:watch_connectivity/watch_connectivity.dart'; import '../../../model/style.dart'; void showWearBottomSheet( BuildContext context, AppInitialization data, String model) async { - final watch = WatchConnectivity(); final timetable = await data.client .getTimeTable(timeNow(), timeNow().add(Duration(days: 7))); @@ -94,9 +93,8 @@ void showWearBottomSheet( color: appStyle.colors.accent, ), onTap: () { - watch.sendMessage({ + WatchSyncHelper.sendMessageToWatch({ "id": "init_data", - // "timetable": timetableArray, "auth": { "studentId": data.client.model.studentId, "studentIdNorm": data.client.model.studentIdNorm, diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index 027b081d..6be0855f 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -77,7 +77,8 @@ UpdateNotifier subPageBack = UpdateNotifier(); HomePage homeScreenPage = HomePage.home; List previousPages = List.empty(growable: true); -class _HomeScreenState extends FirkaState { +class _HomeScreenState extends FirkaState + with WidgetsBindingObserver { _HomeScreenState(); final PageController _pageController = PageController(); @@ -257,9 +258,20 @@ class _HomeScreenState extends FirkaState { } } + bool _prefetchInProgress = false; + bool _didRunLiveActivityLogin = false; + void prefetch() async { if (_prefetched) return; + if (_prefetchInProgress) return; + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) { + logger.info('[Home] prefetch: App is in background, deferring to foreground'); + return; + } + + _prefetchInProgress = true; try { _prefetched = true; @@ -289,18 +301,21 @@ class _HomeScreenState extends FirkaState { await WidgetCacheHelper.refreshIOSWidgets( widget.data.client, widget.data.settings); - final token = pickActiveToken( - tokens: widget.data.tokens, - settings: widget.data.settings, - ); - final studentName = token?.studentId ?? "Student"; - LiveActivityService.onUserLogin( - client: widget.data.client, - studentName: studentName, - settingsStore: widget.data.settings, - ).catchError((e, st) { - logger.severe('LiveActivity registration failed: $e', e, st); - }); + if (!_didRunLiveActivityLogin) { + _didRunLiveActivityLogin = true; + final token = pickActiveToken( + tokens: widget.data.tokens, + settings: widget.data.settings, + ); + final studentName = token?.studentId ?? "Student"; + LiveActivityService.onUserLogin( + client: widget.data.client, + studentName: studentName, + settingsStore: widget.data.settings, + ).catchError((e, st) { + logger.severe('LiveActivity registration failed: $e', e, st); + }); + } } if (!_disposed && @@ -396,6 +411,8 @@ class _HomeScreenState extends FirkaState { ); }); } finally { + _prefetchInProgress = false; + _hasCompletedFirstPrefetch = true; if (!_disposed) { setState(() { _fetching = false; @@ -471,11 +488,11 @@ class _HomeScreenState extends FirkaState { void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); + widget.data.settingsUpdateNotifier.addListener(settingsUpdateListener); - widget.data.profilePictureUpdateNotifier.addListener(() { - if (mounted) setState(() {}); - }); + widget.data.profilePictureUpdateNotifier.addListener(_onProfilePictureUpdated); // Listen for reauth state changes (e.g., when Watch sends a valid token) KretaClient.reauthStateNotifier.addListener(_onReauthStateChanged); @@ -510,6 +527,10 @@ class _HomeScreenState extends FirkaState { if (mounted) setState(() {}); } + void _onProfilePictureUpdated() { + if (mounted) setState(() {}); + } + Future _preloadImages() async { final imagePaths = widget.data.settings.appIcons.keys .map((icon) => "assets/images/icons/$icon.webp") @@ -857,16 +878,51 @@ class _HomeScreenState extends FirkaState { )); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed && !_disposed) { + logger.info('[Home] App resumed to foreground, re-running prefetch'); + _prefetched = false; + _didRunSecondaryICloudRecovery = false; + prefetch(); + + if (Platform.isIOS) { + _refreshLiveActivityOnResume(); + } + } + } + + bool _hasCompletedFirstPrefetch = false; + + void _refreshLiveActivityOnResume() async { + if (!_hasCompletedFirstPrefetch) { + logger.info('[Home] Skipping onAppOpened: first prefetch not yet complete'); + return; + } + try { + final token = pickActiveToken( + tokens: widget.data.tokens, + settings: widget.data.settings, + ); + final studentName = token?.studentId ?? "Student"; + await LiveActivityService.onAppOpened( + client: widget.data.client, + studentName: studentName, + settingsStore: widget.data.settings, + ); + } catch (e) { + logger.warning('[Home] LiveActivity refresh on resume failed: $e'); + } + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _pageController.dispose(); widget.data.settingsUpdateNotifier.removeListener(settingsUpdateListener); widget.data.profilePictureUpdateNotifier - .removeListener(settingsUpdateListener); - - widget.data.profilePictureUpdateNotifier.removeListener(() { - if (mounted) setState(() {}); - }); + .removeListener(_onProfilePictureUpdated); // Remove reauth state listener KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged);