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
parent a22459794a
commit c646ea2d51
14 changed files with 377 additions and 210 deletions

View File

@@ -72,6 +72,18 @@ struct ContentView: View {
guard scenePhase == .active else { return } guard scenePhase == .active else { return }
dataStore.reconcileSharedSessionState() dataStore.reconcileSharedSessionState()
WatchL10n.shared.reconcileFromSharedState() 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 { if shouldAutoRefresh && !dataStore.isLoading {
print("[Watch] Data became stale (>10 min), auto-refreshing...") print("[Watch] Data became stale (>10 min), auto-refreshing...")
Task { Task {
@@ -151,21 +163,21 @@ struct PairingView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 10) {
Image(systemName: iconName) Image(systemName: iconName)
.font(.system(size: 50)) .font(.system(size: 36))
.foregroundColor(.blue) .foregroundColor(.blue)
Text(titleKey.localized) Text(titleKey.localized)
.font(.headline) .font(.headline)
Text(descriptionKey.localized) Text(descriptionKey.localized)
.font(.caption) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
if isWatchSystemPaired && WCSession.default.isReachable { if isWatchSystemPaired {
Button("sync_button".localized) { Button("sync_button".localized) {
onRequestToken?() onRequestToken?()
} }

View File

@@ -74,9 +74,17 @@ class WatchL10n {
} }
func setLanguage(_ language: WatchLanguage) { func setLanguage(_ language: WatchLanguage) {
currentLanguage = language if Thread.isMainThread {
loadStrings() currentLanguage = language
WidgetCenter.shared.reloadAllTimelines() loadStrings()
WidgetCenter.shared.reloadAllTimelines()
} else {
DispatchQueue.main.async { [self] in
currentLanguage = language
loadStrings()
WidgetCenter.shared.reloadAllTimelines()
}
}
} }
func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) { func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) {
@@ -115,6 +123,11 @@ class WatchL10n {
UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey) UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey)
} }
func resetLanguageVersionTracking() {
setLastAppliedSharedLanguageVersion(0)
print("[WatchL10n] Language version tracking reset for account switch")
}
func reconcileFromSharedState() { func reconcileFromSharedState() {
guard syncWithiPhone else { return } guard syncWithiPhone else { return }
guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return } guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return }

View File

@@ -106,6 +106,7 @@ class DataStore {
lastUpdated = nil lastUpdated = nil
error = nil error = nil
recoveryAttempted = false recoveryAttempted = false
WatchL10n.shared.resetLanguageVersionTracking()
} }
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm) setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
} else { } else {
@@ -322,7 +323,16 @@ class DataStore {
} }
} }
private var isRecoveryInProgress: Bool = false
func refreshAllWithRecovery() async { 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() reconcileSharedSessionState()
WatchL10n.shared.refreshFromiPhoneAndSharedState() WatchL10n.shared.refreshFromiPhoneAndSharedState()
@@ -334,7 +344,7 @@ class DataStore {
if shouldRequestTokenFromPhone { if shouldRequestTokenFromPhone {
WatchConnectivityManager.shared.requestTokenFromPhone() WatchConnectivityManager.shared.requestTokenFromPhone()
try? await Task.sleep(nanoseconds: 700_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
checkTokenState() checkTokenState()
} }

View File

@@ -294,6 +294,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
private func handleForceLogoutFromPhone() { private func handleForceLogoutFromPhone() {
TokenManager.shared.deleteToken() TokenManager.shared.deleteToken()
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: false,
activeStudentIdNorm: nil
)
DataStore.shared.clearAll() DataStore.shared.clearAll()
DataStore.shared.resetRecoveryState() DataStore.shared.resetRecoveryState()
DataStore.shared.checkTokenState() DataStore.shared.checkTokenState()
@@ -400,19 +404,24 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
let token = try decoder.decode(WatchToken.self, from: jsonData) let token = try decoder.decode(WatchToken.self, from: jsonData)
let currentToken = TokenManager.shared.loadToken() let currentToken = TokenManager.shared.loadToken()
let isAccountSwitch = currentToken != nil && !token.isSameAccount(as: currentToken!)
let shouldForceAccountSwitch: Bool let shouldForceAccountSwitch: Bool
if incomingSentAtMs > 0, if isAccountSwitch {
let currentToken, if incomingSentAtMs > 0 {
!token.isSameAccount(as: currentToken) { shouldForceAccountSwitch = true
shouldForceAccountSwitch = true } else {
shouldForceAccountSwitch = token.isNewer(than: currentToken!)
}
} else { } else {
shouldForceAccountSwitch = false shouldForceAccountSwitch = false
} }
if incomingSentAtMs <= 0, if incomingSentAtMs <= 0,
let currentToken, let currentToken,
!isAccountSwitch,
!token.isNewer(than: currentToken) { !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 return
} }
@@ -432,6 +441,8 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs) lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
} }
DataStore.shared.clearError()
DataStore.shared.resetRecoveryState()
DataStore.shared.checkTokenState() DataStore.shared.checkTokenState()
Task { Task {

View File

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

View File

@@ -9,11 +9,30 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
private var isFlutterWatchSyncReady = false private var isFlutterWatchSyncReady = false
private var pendingAuthPayloads: [[String: Any]] = [] private var pendingAuthPayloads: [[String: Any]] = []
private var pendingICloudRecoveryNotification = false private var pendingICloudRecoveryNotification = false
private let pendingAuthQueue = DispatchQueue(label: "app.firka.pendingAuthQueue")
override private init() { override private init() {
super.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) { func setup(with messenger: FlutterBinaryMessenger) {
flutterChannel = FlutterMethodChannel( flutterChannel = FlutterMethodChannel(
name: "app.firka/watch_sync", name: "app.firka/watch_sync",
@@ -58,6 +77,8 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
self?.handleClearAllRefreshLeases(result: result) self?.handleClearAllRefreshLeases(result: result)
case "clearSharedLanguageState": case "clearSharedLanguageState":
self?.handleClearSharedLanguageState(result: result) self?.handleClearSharedLanguageState(result: result)
case "sendMessageToWatch":
self?.handleSendMessageToWatch(arguments: call.arguments, result: result)
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }
@@ -160,11 +181,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
} }
private func enqueuePendingAuth(_ authData: [String: Any]) { private func enqueuePendingAuth(_ authData: [String: Any]) {
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) { pendingAuthQueue.sync {
return 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]) { private func forwardTokenToFlutter(_ authData: [String: Any]) {
@@ -188,13 +211,19 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
guard isFlutterWatchSyncReady else { guard isFlutterWatchSyncReady else {
return 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) flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
} }
pendingAuthPayloads.removeAll()
if pendingICloudRecoveryNotification { if pendingICloudRecoveryNotification {
pendingICloudRecoveryNotification = false pendingICloudRecoveryNotification = false
@@ -228,13 +257,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
let session = WCSession.default let session = WCSession.default
do { mergeApplicationContext(["auth": authData])
try session.updateApplicationContext([
"auth": authData
])
} catch {
print("[WatchSessionManager] Failed to update applicationContext for token: \(error)")
}
session.transferUserInfo([ session.transferUserInfo([
"id": "token_update", "id": "token_update",
@@ -271,13 +294,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return return
} }
do { mergeApplicationContext(["widget_data": jsonString])
try WCSession.default.updateApplicationContext(["widget_data": jsonString]) result(nil)
result(nil) print("[WatchSessionManager] Widget data sent to Watch")
print("[WatchSessionManager] Widget data sent to Watch")
} catch {
result(FlutterError(code: "UPDATE_ERROR", message: error.localizedDescription, details: nil))
}
} }
private func handleSendLanguageToWatch(arguments: Any?, result: @escaping FlutterResult) { private func handleSendLanguageToWatch(arguments: Any?, result: @escaping FlutterResult) {
@@ -295,15 +314,11 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode) let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode)
if WCSession.default.activationState == .activated { if WCSession.default.activationState == .activated {
do { mergeApplicationContext([
try WCSession.default.updateApplicationContext([ "language": languageCode,
"language": languageCode, "language_state_version": sharedState.stateVersion
"language_state_version": sharedState.stateVersion ])
]) print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
} catch {
print("[WatchSessionManager] Failed to update applicationContext for language: \(error)")
}
WCSession.default.transferUserInfo([ WCSession.default.transferUserInfo([
"id": "language_update", "id": "language_update",
@@ -601,11 +616,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return return
} }
do { mergeApplicationContext(["force_logout": true])
try WCSession.default.updateApplicationContext(["force_logout": true])
} catch {
print("[WatchSessionManager] Failed to update applicationContext for logout: \(error)")
}
WCSession.default.transferUserInfo([ WCSession.default.transferUserInfo([
"id": "force_logout" "id": "force_logout"
@@ -614,6 +625,28 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
result(nil) 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( func session(
_ session: WCSession, _ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState, activationDidCompleteWith activationState: WCSessionActivationState,
@@ -651,6 +684,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
didReceiveMessage message: [String: Any], didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void 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)") print("[WatchSessionManager] Received message from Watch: \(message)")
guard let action = message["action"] as? String else { guard let action = message["action"] as? String else {
@@ -670,8 +709,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
} }
return 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 tokenData = result as? [String: Any] {
if let error = tokenData["error"] as? String { if let error = tokenData["error"] as? String {
if error == "needsReauth" { if error == "needsReauth" {
@@ -703,7 +741,6 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
} }
} }
} }
}
case "requestLanguage": case "requestLanguage":
if !self.isFlutterWatchSyncReady { if !self.isFlutterWatchSyncReady {
@@ -734,37 +771,56 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return return
} }
DispatchQueue.main.async { var hasReplied = false
flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in let timeoutWorkItem = DispatchWorkItem {
if let languageCode = result as? String, !languageCode.isEmpty { guard !hasReplied else { return }
if let existingState = SharedLanguageStateManager.shared.loadState(), hasReplied = true
existingState.languageCode == languageCode { if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)") print("[WatchSessionManager] Flutter timeout, serving shared language: \(sharedState.languageCode)")
replyHandler([ replyHandler([
"language": languageCode, "language": sharedState.languageCode,
"language_state_version": existingState.stateVersion "language_state_version": sharedState.stateVersion
]) ])
return } 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( flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in
languageCode: languageCode timeoutWorkItem.cancel()
) guard !hasReplied else { return }
print("[WatchSessionManager] Sending language to Watch: \(languageCode)") 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([ replyHandler([
"language": languageCode, "language": languageCode,
"language_state_version": sharedState.stateVersion "language_state_version": existingState.stateVersion
]) ])
} else if let sharedState = SharedLanguageStateManager.shared.loadState() { return
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"])
} }
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 { if !self.isFlutterWatchSyncReady {
print("[WatchSessionManager] Flutter not ready, queueing token from Watch") print("[WatchSessionManager] Flutter not ready, queueing token from Watch")
DispatchQueue.main.async { self.enqueuePendingAuth(tokenData)
self.enqueuePendingAuth(tokenData)
}
replyHandler(["success": true]) replyHandler(["success": true])
return return
} }
print("[WatchSessionManager] Receiving token from Watch") print("[WatchSessionManager] Receiving token from Watch")
DispatchQueue.main.async { self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in if let success = result as? Bool, success {
if let success = result as? Bool, success { print("[WatchSessionManager] Flutter accepted Watch token")
print("[WatchSessionManager] Flutter accepted Watch token") replyHandler(["success": true])
replyHandler(["success": true]) } else if let resultDict = result as? [String: Any],
} else if let resultDict = result as? [String: Any], let success = resultDict["success"] as? Bool, success {
let success = resultDict["success"] as? Bool, success { print("[WatchSessionManager] Flutter accepted Watch token")
print("[WatchSessionManager] Flutter accepted Watch token") replyHandler(["success": true])
replyHandler(["success": true]) } else {
} else { print("[WatchSessionManager] Flutter rejected Watch token")
print("[WatchSessionManager] Flutter rejected Watch token") replyHandler(["error": "rejected"])
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) { func sessionDidBecomeInactive(_ session: WCSession) {
print("[WatchSessionManager] Session did become inactive") print("[WatchSessionManager] Session did become inactive")
} }

View File

@@ -16,8 +16,6 @@ class SharedKeychainManager {
private let deviceName = "Watch" private let deviceName = "Watch"
#endif #endif
private var changeObserver: ((WatchToken) -> Void)?
private init() {} private init() {}
var resolvedAccessGroup: String { 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 // MARK: - Migration from KV Store
func migrateFromKVStoreAndClear() -> WatchToken? { func migrateFromKVStoreAndClear() -> WatchToken? {
let iCloudStore = NSUbiquitousKeyValueStore.default let iCloudStore = NSUbiquitousKeyValueStore.default

View File

@@ -186,61 +186,6 @@ class TokenManager {
private init() { private init() {
runKVStoreMigrationIfNeeded() 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" private let kvStoreMigrationKey = "firka_kv_store_migrated_v1"
@@ -350,8 +295,19 @@ class TokenManager {
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
} }
} }
let previousActiveStudentIdNorm = getActiveStudentIdNorm()
setActiveStudentIdNorm(freshest.token.studentIdNorm) 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() let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss" formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current formatter.timeZone = TimeZone.current
@@ -393,6 +349,9 @@ class TokenManager {
// MARK: - Delete Token // MARK: - Delete Token
func deleteToken() { func deleteToken() {
print("[TokenManager] Deleting token from all storage locations") print("[TokenManager] Deleting token from all storage locations")
SharedSessionStateManager.shared.publishState(hasAnyAccount: false, activeStudentIdNorm: nil)
if let previousToken = loadToken() { if let previousToken = loadToken() {
RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm) RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm)
} else { } else {

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
@@ -61,7 +62,7 @@ class ApiResponse<T> {
} }
class KretaClient { class KretaClient {
bool _tokenMutex = false; Completer<void>? _tokenMutexCompleter;
TokenModel model; TokenModel model;
Isar isar; Isar isar;
@@ -321,22 +322,29 @@ class KretaClient {
Future<T> _mutexCallback<T>(Future<T> Function() callback) async { Future<T> _mutexCallback<T>(Future<T> Function() callback) async {
const maxWaitTime = Duration(seconds: 30); const maxWaitTime = Duration(seconds: 30);
final startTime = DateTime.now();
while (_tokenMutex) { if (_tokenMutexCompleter != null) {
if (DateTime.now().difference(startTime) > maxWaitTime) { try {
logger.warning( await _tokenMutexCompleter!.future.timeout(maxWaitTime, onTimeout: () {
"[Mutex] Timeout waiting for token mutex, forcing release"); logger.warning(
_tokenMutex = false; "[Mutex] Timeout waiting for token mutex, forcing release");
break; if (_tokenMutexCompleter != null && !_tokenMutexCompleter!.isCompleted) {
_tokenMutexCompleter!.complete();
}
});
} catch (_) {
} }
await Future.delayed(const Duration(milliseconds: 50));
} }
_tokenMutex = true;
_tokenMutexCompleter = Completer<void>();
try { try {
return await callback(); return await callback();
} finally { } finally {
_tokenMutex = false; final completer = _tokenMutexCompleter;
_tokenMutexCompleter = null;
if (completer != null && !completer.isCompleted) {
completer.complete();
}
} }
} }

View File

@@ -30,6 +30,7 @@ class LiveActivityService {
static Timer? _updateTimer; static Timer? _updateTimer;
static String? _cachedDeviceToken; static String? _cachedDeviceToken;
static bool _isInitialized = false; static bool _isInitialized = false;
static DateTime? _lastActivityRecreation;
static Timer? _bellDelayDebounceTimer; static Timer? _bellDelayDebounceTimer;
static double? _pendingBellDelay; static double? _pendingBellDelay;
@@ -733,6 +734,7 @@ class LiveActivityService {
return; return;
} }
final liveActivityEnabled = await isEnabled(settingsStore, client); final liveActivityEnabled = await isEnabled(settingsStore, client);
final morningNotificationEnabled = _getCurrentMorningNotificationEnabled() ?? false; final morningNotificationEnabled = _getCurrentMorningNotificationEnabled() ?? false;
_logger.info('onUserLogin: liveActivityEnabled=$liveActivityEnabled, morningNotificationEnabled=$morningNotificationEnabled'); _logger.info('onUserLogin: liveActivityEnabled=$liveActivityEnabled, morningNotificationEnabled=$morningNotificationEnabled');
@@ -949,6 +951,7 @@ class LiveActivityService {
if (liveActivityEnabled) { if (liveActivityEnabled) {
await _startPlaceholderActivity(allLessons, studentName); await _startPlaceholderActivity(allLessons, studentName);
_lastActivityRecreation = DateTime.now();
} }
await _startTimetableMonitoring( await _startTimetableMonitoring(
@@ -969,7 +972,6 @@ class LiveActivityService {
} }
/// Called when app is opened - sends timetable to backend, backend handles updates /// 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({ static Future<void> onAppOpened({
required KretaClient client, required KretaClient client,
required String studentName, required String studentName,
@@ -985,6 +987,20 @@ class LiveActivityService {
return; 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(); final activeActivities = await LiveActivityManager.getActiveActivities();
if (activeActivities.isNotEmpty) { if (activeActivities.isNotEmpty) {
_logger.info('Ending existing activity to refresh push token (8-hour expiration)'); _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)); await Future.delayed(const Duration(milliseconds: 500));
} }
final now = DateTime.now();
final todayStart = DateTime(now.year, now.month, now.day); final todayStart = DateTime(now.year, now.month, now.day);
final startOfWeek = todayStart.subtract(Duration(days: now.weekday - 1)); final startOfWeek = todayStart.subtract(Duration(days: now.weekday - 1));
final endOfWeek = startOfWeek.add(const Duration(days: 6)); final endOfWeek = startOfWeek.add(const Duration(days: 6));
@@ -1000,6 +1015,7 @@ class LiveActivityService {
final allLessons = timetableResponse.response ?? []; final allLessons = timetableResponse.response ?? [];
await _startPlaceholderActivity(allLessons, studentName); await _startPlaceholderActivity(allLessons, studentName);
_lastActivityRecreation = now;
_logger.info('New activity created with fresh push token'); _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 /// Starts a minimal placeholder activity shell - backend will update with real data
static Future<void> _startPlaceholderActivity(List<Lesson> allLessons, String studentName) async { static Future<void> _startPlaceholderActivity(List<Lesson> 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) // Always end existing activities to ensure fresh token (8-hour expiration)
final activeActivities = await LiveActivityManager.getActiveActivities(); final activeActivities = await LiveActivityManager.getActiveActivities();
if (activeActivities.isNotEmpty) { if (activeActivities.isNotEmpty) {
@@ -1432,10 +1453,8 @@ class LiveActivityService {
static Future<void> _clearCache() async { static Future<void> _clearCache() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_deviceTokenKey);
await prefs.remove(_lastTimetableUpdateKey); await prefs.remove(_lastTimetableUpdateKey);
await prefs.remove(_isRegisteredKey); await prefs.remove(_isRegisteredKey);
_cachedDeviceToken = null;
} }
/// Try to get cached token or wait a short period until iOS provides it /// Try to get cached token or wait a short period until iOS provides it

View File

@@ -466,11 +466,37 @@ class WatchSyncHelper {
'[WatchSync] Token recovered from iCloud notification received'); '[WatchSync] Token recovered from iCloud notification received');
await _handleTokenRecoveredFromiCloud(); await _handleTokenRecoveredFromiCloud();
return null; return null;
case 'onWatchMessage':
_handleWatchMessage(call.arguments);
return null;
default: default:
return null; 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) /// 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 /// This clears the reauth flag if it was set, since we now have a valid token
static Future<void> _handleTokenRecoveredFromiCloud() async { static Future<void> _handleTokenRecoveredFromiCloud() async {
@@ -545,6 +571,13 @@ class WatchSyncHelper {
return tokenData; 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 { static Future<void> sendTokenToWatch() async {
if (!Platform.isIOS) return; if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled(); 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:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:watch_connectivity/watch_connectivity.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'helpers/db/models/homework_cache_model.dart'; import 'helpers/db/models/homework_cache_model.dart';
import 'helpers/update_notifier.dart'; import 'helpers/update_notifier.dart';
@@ -544,30 +543,27 @@ class InitializationScreen extends StatelessWidget {
}()); }());
} }
var watch = WatchConnectivity();
if (!initData.hasWatchListener) { if (!initData.hasWatchListener) {
initData.hasWatchListener = true; initData.hasWatchListener = true;
watch.messageStream.listen((e) { WatchSyncHelper.onWatchMessage = (msg) {
var msg = e.entries.toMap();
logger.finest("WatchOS IPC [Watch -> Phone]: ${msg["id"]}"); logger.finest("WatchOS IPC [Watch -> Phone]: ${msg["id"]}");
switch (msg["id"]) { switch (msg["id"]) {
case "ping": case "ping":
if (initData.tokens.isNotEmpty) { if (initData.tokens.isNotEmpty) {
logger.finest("WatchOS IPC [Phone -> Watch]: pong"); 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( navigatorKey.currentState?.push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => HomeScreen(initData, true, builder: (context) => HomeScreen(initData, true,
model: msg["model"] as String), model: msg["model"] as String? ?? "unknown"),
), ),
); );
} }
} }
}); };
} }
if (snapshot.data!.tokens.isEmpty) { if (snapshot.data!.tokens.isEmpty) {

View File

@@ -1,15 +1,14 @@
import 'package:firka/helpers/debug_helper.dart'; import 'package:firka/helpers/debug_helper.dart';
import 'package:firka/helpers/ui/firka_card.dart'; import 'package:firka/helpers/ui/firka_card.dart';
import 'package:firka/helpers/watch_sync_helper.dart';
import 'package:firka/main.dart'; import 'package:firka/main.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:watch_connectivity/watch_connectivity.dart';
import '../../../model/style.dart'; import '../../../model/style.dart';
void showWearBottomSheet( void showWearBottomSheet(
BuildContext context, AppInitialization data, String model) async { BuildContext context, AppInitialization data, String model) async {
final watch = WatchConnectivity();
final timetable = await data.client final timetable = await data.client
.getTimeTable(timeNow(), timeNow().add(Duration(days: 7))); .getTimeTable(timeNow(), timeNow().add(Duration(days: 7)));
@@ -94,9 +93,8 @@ void showWearBottomSheet(
color: appStyle.colors.accent, color: appStyle.colors.accent,
), ),
onTap: () { onTap: () {
watch.sendMessage({ WatchSyncHelper.sendMessageToWatch({
"id": "init_data", "id": "init_data",
// "timetable": timetableArray,
"auth": { "auth": {
"studentId": data.client.model.studentId, "studentId": data.client.model.studentId,
"studentIdNorm": data.client.model.studentIdNorm, "studentIdNorm": data.client.model.studentIdNorm,

View File

@@ -77,7 +77,8 @@ UpdateNotifier subPageBack = UpdateNotifier();
HomePage homeScreenPage = HomePage.home; HomePage homeScreenPage = HomePage.home;
List<HomePage> previousPages = List.empty(growable: true); List<HomePage> previousPages = List.empty(growable: true);
class _HomeScreenState extends FirkaState<HomeScreen> { class _HomeScreenState extends FirkaState<HomeScreen>
with WidgetsBindingObserver {
_HomeScreenState(); _HomeScreenState();
final PageController _pageController = PageController(); final PageController _pageController = PageController();
@@ -257,9 +258,20 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
} }
} }
bool _prefetchInProgress = false;
bool _didRunLiveActivityLogin = false;
void prefetch() async { void prefetch() async {
if (_prefetched) return; 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 { try {
_prefetched = true; _prefetched = true;
@@ -289,18 +301,21 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
await WidgetCacheHelper.refreshIOSWidgets( await WidgetCacheHelper.refreshIOSWidgets(
widget.data.client, widget.data.settings); widget.data.client, widget.data.settings);
final token = pickActiveToken( if (!_didRunLiveActivityLogin) {
tokens: widget.data.tokens, _didRunLiveActivityLogin = true;
settings: widget.data.settings, final token = pickActiveToken(
); tokens: widget.data.tokens,
final studentName = token?.studentId ?? "Student"; settings: widget.data.settings,
LiveActivityService.onUserLogin( );
client: widget.data.client, final studentName = token?.studentId ?? "Student";
studentName: studentName, LiveActivityService.onUserLogin(
settingsStore: widget.data.settings, client: widget.data.client,
).catchError((e, st) { studentName: studentName,
logger.severe('LiveActivity registration failed: $e', e, st); settingsStore: widget.data.settings,
}); ).catchError((e, st) {
logger.severe('LiveActivity registration failed: $e', e, st);
});
}
} }
if (!_disposed && if (!_disposed &&
@@ -396,6 +411,8 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
); );
}); });
} finally { } finally {
_prefetchInProgress = false;
_hasCompletedFirstPrefetch = true;
if (!_disposed) { if (!_disposed) {
setState(() { setState(() {
_fetching = false; _fetching = false;
@@ -471,11 +488,11 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
widget.data.settingsUpdateNotifier.addListener(settingsUpdateListener); widget.data.settingsUpdateNotifier.addListener(settingsUpdateListener);
widget.data.profilePictureUpdateNotifier.addListener(() { widget.data.profilePictureUpdateNotifier.addListener(_onProfilePictureUpdated);
if (mounted) setState(() {});
});
// Listen for reauth state changes (e.g., when Watch sends a valid token) // Listen for reauth state changes (e.g., when Watch sends a valid token)
KretaClient.reauthStateNotifier.addListener(_onReauthStateChanged); KretaClient.reauthStateNotifier.addListener(_onReauthStateChanged);
@@ -510,6 +527,10 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
void _onProfilePictureUpdated() {
if (mounted) setState(() {});
}
Future<void> _preloadImages() async { Future<void> _preloadImages() async {
final imagePaths = widget.data.settings.appIcons.keys final imagePaths = widget.data.settings.appIcons.keys
.map((icon) => "assets/images/icons/$icon.webp") .map((icon) => "assets/images/icons/$icon.webp")
@@ -857,16 +878,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 @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pageController.dispose(); _pageController.dispose();
widget.data.settingsUpdateNotifier.removeListener(settingsUpdateListener); widget.data.settingsUpdateNotifier.removeListener(settingsUpdateListener);
widget.data.profilePictureUpdateNotifier widget.data.profilePictureUpdateNotifier
.removeListener(settingsUpdateListener); .removeListener(_onProfilePictureUpdated);
widget.data.profilePictureUpdateNotifier.removeListener(() {
if (mounted) setState(() {});
});
// Remove reauth state listener // Remove reauth state listener
KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged); KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged);