1
0
forked from firka/firka

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.
This commit is contained in:
Horváth Gergely
2026-02-27 22:44:53 +01:00
committed by 4831c0
parent 0c4bc4cd40
commit 40a1e8f459
14 changed files with 387 additions and 216 deletions

View File

@@ -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?()
}

View File

@@ -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 }

View File

@@ -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()
}

View File

@@ -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 {

View File

@@ -69,6 +69,10 @@ struct SettingsView: View {
private func logout() {
TokenManager.shared.deleteToken()
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: false,
activeStudentIdNorm: nil
)
DataStore.shared.clearAll()
}
}

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
@@ -56,7 +57,7 @@ class ApiResponse<T> {
}
class KretaClient {
bool _tokenMutex = false;
Completer<void>? _tokenMutexCompleter;
TokenModel model;
Isar isar;
@@ -328,23 +329,28 @@ class KretaClient {
Future<T> _mutexCallback<T>(Future<T> 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<void>();
try {
return await callback();
} finally {
_tokenMutex = false;
final completer = _tokenMutexCompleter;
_tokenMutexCompleter = null;
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
}

View File

@@ -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<void> 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<void> _startPlaceholderActivity(
List<Lesson> 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<void> _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

View File

@@ -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<String, dynamic> message)? onWatchMessage;
static void _handleWatchMessage(dynamic arguments) {
if (arguments == null) return;
try {
final Map<String, dynamic> message;
if (arguments is Map<String, dynamic>) {
message = arguments;
} else if (arguments is Map) {
message = Map<String, dynamic>.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<void> _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<void> sendMessageToWatch(Map<String, dynamic> message) async {
if (!Platform.isIOS) return;
await _invokeMethodWithTimeout('sendMessageToWatch', message);
}
static Future<void> sendTokenToWatch() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();

View File

@@ -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) {

View File

@@ -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,

View File

@@ -84,7 +84,8 @@ UpdateNotifier subPageBack = UpdateNotifier();
HomePage homeScreenPage = HomePage.home;
List<HomePage> previousPages = List.empty(growable: true);
class _HomeScreenState extends FirkaState<HomeScreen> {
class _HomeScreenState extends FirkaState<HomeScreen>
with WidgetsBindingObserver {
_HomeScreenState();
final PageController _pageController = PageController();
@@ -267,9 +268,20 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
}
}
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<HomeScreen> {
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<HomeScreen> {
);
});
} finally {
_prefetchInProgress = false;
_hasCompletedFirstPrefetch = true;
if (!_disposed) {
setState(() {
_fetching = false;
@@ -490,11 +507,11 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
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<HomeScreen> {
if (mounted) setState(() {});
}
void _onProfilePictureUpdated() {
if (mounted) setState(() {});
}
Future<void> _preloadImages() async {
final imagePaths = widget.data.settings.appIcons.keys
.map((icon) => "assets/images/icons/$icon.webp")
@@ -601,7 +622,11 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
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<HomeScreen> {
);
}
@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);