diff --git a/firka/ios/FirkaWatch Watch App/ContentView.swift b/firka/ios/FirkaWatch Watch App/ContentView.swift index 9308dc5..919c840 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 2784cf6..5d7be8f 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 c4007e2..f0ffe21 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 7a2b2d1..ce5175b 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 3820cbc..9e498d2 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 be35d62..786bf8c 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 4b15607..5e14507 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 b357c51..90f9d4f 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 a5e6ee5..f00db8e 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'; @@ -56,7 +57,7 @@ class ApiResponse { } class KretaClient { - bool _tokenMutex = false; + Completer? _tokenMutexCompleter; TokenModel model; Isar isar; @@ -328,23 +329,28 @@ 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; - } - await Future.delayed(const Duration(milliseconds: 50)); + 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 (_) {} } - _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 1587b15..9404816 100644 --- a/firka/lib/helpers/live_activity_service.dart +++ b/firka/lib/helpers/live_activity_service.dart @@ -31,6 +31,7 @@ class LiveActivityService { static Timer? _updateTimer; static String? _cachedDeviceToken; static bool _isInitialized = false; + static DateTime? _lastActivityRecreation; static Timer? _bellDelayDebounceTimer; static double? _pendingBellDelay; @@ -863,6 +864,7 @@ class LiveActivityService { return; } + final liveActivityEnabled = await isEnabled(settingsStore, client); final morningNotificationEnabled = _getCurrentMorningNotificationEnabled() ?? false; @@ -1145,6 +1147,7 @@ class LiveActivityService { if (liveActivityEnabled) { await _startPlaceholderActivity(allLessons, studentName); + _lastActivityRecreation = DateTime.now(); } await _startTimetableMonitoring( @@ -1165,7 +1168,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, @@ -1181,6 +1183,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( @@ -1190,7 +1206,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)); @@ -1201,6 +1216,7 @@ class LiveActivityService { final allLessons = timetableResponse.response ?? []; await _startPlaceholderActivity(allLessons, studentName); + _lastActivityRecreation = now; _logger.info('New activity created with fresh push token'); @@ -1585,8 +1601,16 @@ class LiveActivityService { /// Starts a minimal placeholder activity shell - backend will update with real data static Future _startPlaceholderActivity( List allLessons, - String studentName, - ) async { + 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) { @@ -1704,10 +1728,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 5294ef6..e095897 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -468,11 +468,37 @@ class WatchSyncHelper { ); 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 { @@ -551,6 +577,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 546f364..5b46e6b 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'; @@ -566,33 +565,30 @@ 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 f532e3d..6c4fc58 100644 --- a/firka/lib/ui/phone/pages/extras/main_wear_pair.dart +++ b/firka/lib/ui/phone/pages/extras/main_wear_pair.dart @@ -1,9 +1,9 @@ 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'; @@ -12,7 +12,6 @@ void showWearBottomSheet( AppInitialization data, String model, ) async { - final watch = WatchConnectivity(); final timetable = await data.client.getTimeTable( timeNow(), timeNow().add(Duration(days: 7)), @@ -108,9 +107,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 547ecd3..a0b4d9d 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -84,7 +84,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(); @@ -267,9 +268,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; @@ -302,18 +314,21 @@ class _HomeScreenState extends FirkaState { 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 && @@ -413,6 +428,8 @@ class _HomeScreenState extends FirkaState { ); }); } finally { + _prefetchInProgress = false; + _hasCompletedFirstPrefetch = true; if (!_disposed) { setState(() { _fetching = false; @@ -490,11 +507,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); @@ -529,6 +546,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") @@ -601,7 +622,11 @@ class _HomeScreenState extends FirkaState { if (widget.watchPair && !pairingDone) { Timer.run(() { - showWearBottomSheet(context, widget.data, widget.model!); + showWearBottomSheet( + context, + widget.data, + widget.model ?? "unknown", + ); // pairingDone = true; }); @@ -892,17 +917,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(() {}); - }); + widget.data.profilePictureUpdateNotifier + .removeListener(_onProfilePictureUpdated); // Remove reauth state listener KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged);