Add shared session/language state & refresh leases

Introduce shared session and language state plus cross-device refresh leases to improve Watch/iPhone sync. Adds SharedSessionStateManager, SharedLanguageStateManager and RefreshLeaseManager (keychain-backed) and exposes accessGroup via SharedKeychainManager. Wire shared state into WatchSessionManager (publish/load language and session state, immediate token send via message + userInfo + applicationContext, reachability check, lease-related Flutter handlers) and into WatchConnectivityManager (parse versions, apply immediate token updates). Update DataStore and WatchL10n to reconcile shared session/language state, handle stale account switching, and request tokens from phone when needed. Add TokenManager watch-side lease wrapper for refresh coordination, UI tweaks for pairing/no-token messages and icons, small fixes (date parsing in widget provider, background refresh cadence to ~15 min, time-since localization keys and usage), and various helper utilities for parsing int64 and state versioning.
This commit is contained in:
Horváth Gergely
2026-02-16 19:05:17 +01:00
parent 748bff63ea
commit 58c16e9aa8
15 changed files with 1545 additions and 100 deletions

View File

@@ -44,6 +44,8 @@ struct ContentView: View {
}
}
.task {
dataStore.reconcileSharedSessionState()
WatchL10n.shared.reconcileFromSharedState()
dataStore.checkTokenState()
dataStore.loadFromCache()
if dataStore.hasToken {
@@ -54,6 +56,8 @@ struct ContentView: View {
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active && oldPhase != .active {
dataStore.reconcileSharedSessionState()
WatchL10n.shared.reconcileFromSharedState()
if shouldAutoRefresh {
print("[Watch] App came to foreground, data is stale (>10 min), refreshing...")
Task {
@@ -65,7 +69,10 @@ struct ContentView: View {
}
}
.onReceive(staleCheckTimer) { _ in
if scenePhase == .active && shouldAutoRefresh && !dataStore.isLoading {
guard scenePhase == .active else { return }
dataStore.reconcileSharedSessionState()
WatchL10n.shared.reconcileFromSharedState()
if shouldAutoRefresh && !dataStore.isLoading {
print("[Watch] Data became stale (>10 min), auto-refreshing...")
Task {
await dataStore.refreshAllWithRecovery()
@@ -124,22 +131,41 @@ struct ContentView: View {
struct PairingView: View {
var onRequestToken: (() -> Void)?
private var isWatchSystemPaired: Bool {
guard WCSession.isSupported() else { return false }
return WCSession.default.isCompanionAppInstalled
}
private var titleKey: String {
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
}
private var descriptionKey: String {
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
}
private var iconName: String {
isWatchSystemPaired
? "person.crop.circle.badge.exclamationmark"
: "iphone.and.arrow.right.inward"
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: "iphone.and.arrow.right.inward")
Image(systemName: iconName)
.font(.system(size: 50))
.foregroundColor(.blue)
Text("pair_with_iphone".localized)
Text(titleKey.localized)
.font(.headline)
Text("open_firka_on_iphone".localized)
Text(descriptionKey.localized)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if WCSession.default.isReachable {
if isWatchSystemPaired && WCSession.default.isReachable {
Button("sync_button".localized) {
onRequestToken?()
}

View File

@@ -30,6 +30,7 @@ class WatchL10n {
private let languageKey = "watch_language"
private let syncWithiPhoneKey = "watch_sync_language_with_iphone"
private let lastAppliedSharedLanguageVersionKey = "watch_last_applied_shared_language_version"
private static let appGroupID = "group.app.firka.firkaa"
private var appGroupDefaults: UserDefaults? {
UserDefaults(suiteName: Self.appGroupID)
@@ -45,8 +46,9 @@ class WatchL10n {
var syncWithiPhone: Bool {
didSet {
UserDefaults.standard.set(syncWithiPhone, forKey: syncWithiPhoneKey)
appGroupDefaults?.set(syncWithiPhone, forKey: syncWithiPhoneKey)
if syncWithiPhone {
requestLanguageFromiPhone()
refreshFromiPhoneAndSharedState()
}
}
}
@@ -56,7 +58,13 @@ class WatchL10n {
private init() {
let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu"
self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian
self.syncWithiPhone = UserDefaults.standard.bool(forKey: syncWithiPhoneKey)
if let storedSyncPref = UserDefaults.standard.object(forKey: syncWithiPhoneKey) as? Bool {
self.syncWithiPhone = storedSyncPref
} else {
self.syncWithiPhone = true
UserDefaults.standard.set(true, forKey: syncWithiPhoneKey)
appGroupDefaults?.set(true, forKey: syncWithiPhoneKey)
}
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
loadStrings()
}
@@ -71,11 +79,60 @@ class WatchL10n {
WidgetCenter.shared.reloadAllTimelines()
}
func updateFromiPhone(languageCode: String) {
func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) {
guard syncWithiPhone else { return }
if let language = WatchLanguage(rawValue: languageCode) {
setLanguage(language)
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
if let sharedStateVersion,
sharedStateVersion > 0,
sharedStateVersion < lastAppliedVersion {
print("[WatchL10n] Ignoring stale WC language update (version: \(sharedStateVersion), lastApplied: \(lastAppliedVersion))")
return
}
if let language = WatchLanguage(rawValue: languageCode) {
if language != currentLanguage {
setLanguage(language)
}
if let sharedStateVersion, sharedStateVersion > 0 {
setLastAppliedSharedLanguageVersion(max(lastAppliedVersion, sharedStateVersion))
}
}
}
private func parseInt64(_ value: Any?) -> Int64? {
if let value = value as? Int64 { return value }
if let value = value as? Int { return Int64(value) }
if let value = value as? Double { return Int64(value) }
if let value = value as? String, let parsed = Int64(value) { return parsed }
return nil
}
private func lastAppliedSharedLanguageVersion() -> Int64 {
parseInt64(UserDefaults.standard.object(forKey: lastAppliedSharedLanguageVersionKey)) ?? 0
}
private func setLastAppliedSharedLanguageVersion(_ value: Int64) {
UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey)
}
func reconcileFromSharedState() {
guard syncWithiPhone else { return }
guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return }
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
guard sharedState.stateVersion > lastAppliedVersion else { return }
if let language = WatchLanguage(rawValue: sharedState.languageCode) {
if language != currentLanguage {
setLanguage(language)
}
setLastAppliedSharedLanguageVersion(sharedState.stateVersion)
}
}
func refreshFromiPhoneAndSharedState() {
guard syncWithiPhone else { return }
requestLanguageFromiPhone()
reconcileFromSharedState()
}
private func requestLanguageFromiPhone() {
@@ -113,12 +170,20 @@ class WatchL10n {
"no_more_lessons": "Ma nincs több órád",
"pair_with_iphone": "Párosítsd az iPhone-oddal",
"open_firka_on_iphone": "Nyisd meg a Firka appot az iPhone-odon",
"login_on_iphone": "Jelentkezz be iPhone-on",
"open_and_login_on_iphone": "Nyisd meg a Firka appot iPhone-on, és lépj be egy fiókba",
"updated": "Frissítve: %@",
"minutes": "perc",
"time_now": "most",
"time_hours_minutes": "%d ó %d p",
"time_hours": "%d óra",
"time_minutes_only": "%d perc",
"time_since_minutes_one": "1 perce",
"time_since_minutes_many": "%d perce",
"time_since_hours_one": "1 órája",
"time_since_hours_many": "%d órája",
"time_since_days_one": "1 napja",
"time_since_days_many": "%d napja",
// Timetable View
"free_day": "Szabad nap",
@@ -198,12 +263,20 @@ class WatchL10n {
"no_more_lessons": "No more lessons today",
"pair_with_iphone": "Pair with iPhone",
"open_firka_on_iphone": "Open Firka app on your iPhone",
"login_on_iphone": "Sign in on iPhone",
"open_and_login_on_iphone": "Open Firka on your iPhone and sign in to an account",
"updated": "Updated: %@",
"minutes": "min",
"time_now": "now",
"time_hours_minutes": "%dh %dm",
"time_hours": "%d hours",
"time_minutes_only": "%d min",
"time_since_minutes_one": "1 min ago",
"time_since_minutes_many": "%d mins ago",
"time_since_hours_one": "1 hour ago",
"time_since_hours_many": "%d hours ago",
"time_since_days_one": "1 day ago",
"time_since_days_many": "%d days ago",
// Timetable View
"free_day": "Free Day",
@@ -283,12 +356,20 @@ class WatchL10n {
"no_more_lessons": "Keine Stunden mehr heute",
"pair_with_iphone": "Mit iPhone koppeln",
"open_firka_on_iphone": "Öffne Firka auf deinem iPhone",
"login_on_iphone": "Auf iPhone anmelden",
"open_and_login_on_iphone": "Öffne Firka auf deinem iPhone und melde dich mit einem Konto an",
"updated": "Aktualisiert: %@",
"minutes": "Min",
"time_now": "jetzt",
"time_hours_minutes": "%d Std %d Min",
"time_hours": "%d Stunden",
"time_minutes_only": "%d Min",
"time_since_minutes_one": "vor 1 Min",
"time_since_minutes_many": "vor %d Min",
"time_since_hours_one": "vor 1 Std",
"time_since_hours_many": "vor %d Std",
"time_since_days_one": "vor 1 Tag",
"time_since_days_many": "vor %d Tagen",
// Timetable View
"free_day": "Freier Tag",

View File

@@ -32,6 +32,8 @@ class DataStore {
private let appGroupID = "group.app.firka.firkaa"
private let cacheFileName = "watch_data.json"
private let lastHandledSessionStateVersionKey = "firka.watch.last_handled_session_state_version"
private let lastHandledSessionActiveStudentIdNormKey = "firka.watch.last_handled_session_active_student_id_norm"
private init() {
checkTokenState()
@@ -48,6 +50,72 @@ class DataStore {
print("[Watch] Token state updated: hasToken = \(hasToken)")
}
private func parseInt64(_ value: Any?) -> Int64? {
if let value = value as? Int64 { return value }
if let value = value as? Int { return Int64(value) }
if let value = value as? Double { return Int64(value) }
if let value = value as? String, let parsed = Int64(value) { return parsed }
return nil
}
private func lastHandledSessionStateVersion() -> Int64 {
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionStateVersionKey)) ?? 0
}
private func setLastHandledSessionStateVersion(_ value: Int64) {
UserDefaults.standard.set(value, forKey: lastHandledSessionStateVersionKey)
}
private func lastHandledSessionActiveStudentIdNorm() -> Int64? {
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionActiveStudentIdNormKey))
}
private func setLastHandledSessionActiveStudentIdNorm(_ value: Int64?) {
if let value {
UserDefaults.standard.set(value, forKey: lastHandledSessionActiveStudentIdNormKey)
} else {
UserDefaults.standard.removeObject(forKey: lastHandledSessionActiveStudentIdNormKey)
}
}
func reconcileSharedSessionState() {
guard let state = SharedSessionStateManager.shared.loadState() else {
return
}
let lastVersion = lastHandledSessionStateVersion()
guard state.stateVersion > lastVersion else {
return
}
if !state.hasAnyAccount {
print("[Watch] Shared session state: no active iPhone account, clearing watch state")
clearAll()
resetRecoveryState()
setLastHandledSessionStateVersion(state.stateVersion)
setLastHandledSessionActiveStudentIdNorm(nil)
return
}
if let activeStudentIdNorm = state.activeStudentIdNorm {
let lastHandledActiveStudentIdNorm = lastHandledSessionActiveStudentIdNorm()
if lastHandledActiveStudentIdNorm != activeStudentIdNorm {
print("[Watch] Shared session switched active account to \(activeStudentIdNorm), clearing stale cache")
clearCache()
data = nil
lastUpdated = nil
error = nil
recoveryAttempted = false
}
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
} else {
setLastHandledSessionActiveStudentIdNorm(nil)
}
setLastHandledSessionStateVersion(state.stateVersion)
checkTokenState()
}
// MARK: - Cache Loading
func loadFromCache() {
@@ -255,6 +323,21 @@ class DataStore {
}
func refreshAllWithRecovery() async {
reconcileSharedSessionState()
WatchL10n.shared.refreshFromiPhoneAndSharedState()
let sharedActiveStudentIdNorm = SharedSessionStateManager.shared.loadState()?.activeStudentIdNorm
let localStudentIdNorm = TokenManager.shared.loadToken()?.studentIdNorm
let shouldRequestTokenFromPhone =
!hasValidToken ||
(sharedActiveStudentIdNorm != nil && localStudentIdNorm != sharedActiveStudentIdNorm)
if shouldRequestTokenFromPhone {
WatchConnectivityManager.shared.requestTokenFromPhone()
try? await Task.sleep(nanoseconds: 700_000_000)
checkTokenState()
}
await refreshAll()
guard error == "token_expired" || error == "no_token" else {
@@ -428,18 +511,24 @@ class DataStore {
// Minutes
let minutes = Int(elapsed / 60)
if minutes < 60 {
return minutes == 1 ? "1 perce" : "\(minutes) perce"
return minutes == 1
? "time_since_minutes_one".localized
: "time_since_minutes_many".localized(minutes)
}
// Hours
let hours = Int(elapsed / 3600)
if hours < 24 {
return hours == 1 ? "1 órája" : "\(hours) órája"
return hours == 1
? "time_since_hours_one".localized
: "time_since_hours_many".localized(hours)
}
// Days
let days = Int(elapsed / 86400)
return days == 1 ? "1 napja" : "\(days) napja"
return days == 1
? "time_since_days_one".localized
: "time_since_days_many".localized(days)
}
/// Returns true if data is stale (> 1 hour old or never updated)

View File

@@ -37,6 +37,22 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
return nil
}
private func parseInt64(_ value: Any?) -> Int64? {
if let value = value as? Int64 {
return value
}
if let value = value as? Int {
return Int64(value)
}
if let value = value as? Double {
return Int64(value)
}
if let value = value as? String, let parsed = Int64(value) {
return parsed
}
return nil
}
func activate() {
print("[Watch] WatchConnectivityManager.activate() called")
if WCSession.isSupported() {
@@ -94,6 +110,17 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
) {
print("[Watch] didReceiveMessage called: \(message)")
if let messageId = message["id"] as? String, messageId == "token_update" {
if let authDict = message["auth"] as? [String: Any] {
print("[Watch] Received immediate token_update via sendMessage")
processAuthData(authDict)
replyHandler(["success": true])
} else {
replyHandler(["error": "no_auth"])
}
return
}
guard let action = message["action"] as? String else {
replyHandler(["error": "no_action"])
return
@@ -223,8 +250,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
}
if let language = context["language"] as? String {
let sharedStateVersion =
parseInt64(context["language_state_version"]) ??
parseInt64(context["languageStateVersion"])
print("[Watch] Received language from iPhone: \(language)")
WatchL10n.shared.updateFromiPhone(languageCode: language)
WatchL10n.shared.updateFromiPhone(
languageCode: language,
sharedStateVersion: sharedStateVersion
)
}
}
@@ -238,8 +271,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
}
case "language_update":
if let language = userInfo["language"] as? String {
let sharedStateVersion =
parseInt64(userInfo["language_state_version"]) ??
parseInt64(userInfo["languageStateVersion"])
print("[Watch] Received language_update via userInfo: \(language)")
WatchL10n.shared.updateFromiPhone(languageCode: language)
WatchL10n.shared.updateFromiPhone(
languageCode: language,
sharedStateVersion: sharedStateVersion
)
}
case "reauth_required":
print("[Watch] Received reauth_required notification from iPhone")
@@ -322,8 +361,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
print("[Watch] Received language response from iPhone")
DispatchQueue.main.async {
if let language = response["language"] as? String {
let sharedStateVersion =
self.parseInt64(response["language_state_version"]) ??
self.parseInt64(response["languageStateVersion"])
print("[Watch] Language received from iPhone: \(language)")
WatchL10n.shared.updateFromiPhone(languageCode: language)
WatchL10n.shared.updateFromiPhone(
languageCode: language,
sharedStateVersion: sharedStateVersion
)
}
}
},
@@ -379,6 +424,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
forceAccountSwitch: shouldForceAccountSwitch
)
print("[Watch] Token saved successfully")
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: true,
activeStudentIdNorm: token.studentIdNorm
)
if incomingSentAtMs > 0 {
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import WatchConnectivity
internal import Combine
struct HomeView: View {
@@ -438,17 +439,36 @@ struct HomeView: View {
// MARK: - No Token View
private var isWatchSystemPaired: Bool {
guard WCSession.isSupported() else { return false }
return WCSession.default.isCompanionAppInstalled
}
private var noTokenTitleKey: String {
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
}
private var noTokenDescriptionKey: String {
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
}
private var noTokenIconName: String {
isWatchSystemPaired
? "person.crop.circle.badge.exclamationmark"
: "iphone.and.arrow.right.inward"
}
private var noTokenView: some View {
VStack(spacing: 12) {
Image(systemName: "iphone.and.arrow.right.inward")
Image(systemName: noTokenIconName)
.font(.system(size: 44))
.foregroundColor(.blue)
Text("pair_with_iphone".localized)
Text(noTokenTitleKey.localized)
.font(.headline)
.multilineTextAlignment(.center)
Text("open_firka_on_iphone".localized)
Text(noTokenDescriptionKey.localized)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)

View File

@@ -38,7 +38,12 @@ struct TimetableProvider: AppIntentTimelineProvider {
private func parseNextSchoolDayDate(_ dateString: String?) -> Date? {
guard let dateString = dateString else { return nil }
return Self.dateFormatter.date(from: dateString)
if let date = Self.dateFormatter.date(from: dateString) {
return date
}
let trimmed = String(dateString.prefix(10))
return Self.dateFormatter.date(from: trimmed)
}
func placeholder(in context: Context) -> TimetableEntry {

View File

@@ -213,12 +213,12 @@ import BackgroundTasks
let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier)
// IMPORTANT: iOS may delay this based on system conditions and user behavior
// The default setting is 30 minutes
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
// Requested cadence: 15 minutes (best effort, not guaranteed by iOS)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
print("[AppDelegate] Background refresh scheduled for ~30 minutes from now")
print("[AppDelegate] Background refresh scheduled for ~15 minutes from now")
} catch {
print("[AppDelegate] Could not schedule background refresh: \(error)")
}

View File

@@ -38,12 +38,26 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
self?.handleSaveTokenToiCloud(arguments: call.arguments, result: result)
case "isWatchAppInstalled":
self?.handleIsWatchAppInstalled(result: result)
case "isWatchReachable":
self?.handleIsWatchReachable(result: result)
case "clearICloudToken":
self?.handleClearICloudToken(result: result)
case "sendLogoutToWatch":
self?.handleSendLogoutToWatch(result: result)
case "watchSyncReady":
self?.handleWatchSyncReady(result: result)
case "waitForPeerRefreshLease":
self?.handleWaitForPeerRefreshLease(arguments: call.arguments, result: result)
case "acquireRefreshLease":
self?.handleAcquireRefreshLease(arguments: call.arguments, result: result)
case "releaseRefreshLease":
self?.handleReleaseRefreshLease(arguments: call.arguments, result: result)
case "clearRefreshLeaseForAccount":
self?.handleClearRefreshLeaseForAccount(arguments: call.arguments, result: result)
case "clearAllRefreshLeases":
self?.handleClearAllRefreshLeases(result: result)
case "clearSharedLanguageState":
self?.handleClearSharedLanguageState(result: result)
default:
result(FlutterMethodNotImplemented)
}
@@ -88,6 +102,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return nil
}
private func parseLeaseOwner(_ value: Any?) -> RefreshLeaseOwner? {
guard let raw = value as? String else {
return nil
}
return RefreshLeaseOwner(rawValue: raw)
}
private func tokenPayload(from token: WatchToken) -> [String: Any] {
var tokenData: [String: Any] = [
"studentId": token.studentId,
@@ -194,21 +215,49 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return
}
guard WCSession.default.isWatchAppInstalled else {
print("[WatchSessionManager] No paired Watch app, skipping token send")
result(nil)
return
}
guard WCSession.default.activationState == .activated else {
result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil))
return
}
let session = WCSession.default
do {
WCSession.default.transferUserInfo([
"id": "token_update",
try session.updateApplicationContext([
"auth": authData
])
result(nil)
print("[WatchSessionManager] Token sent to Watch")
} catch {
result(FlutterError(code: "TRANSFER_ERROR", message: error.localizedDescription, details: nil))
print("[WatchSessionManager] Failed to update applicationContext for token: \(error)")
}
session.transferUserInfo([
"id": "token_update",
"auth": authData
])
if session.isReachable {
session.sendMessage(
[
"id": "token_update",
"auth": authData
],
replyHandler: { _ in
print("[WatchSessionManager] Token delivered to Watch via sendMessage")
},
errorHandler: { error in
print("[WatchSessionManager] Failed immediate token send via sendMessage: \(error.localizedDescription)")
}
)
}
result(nil)
print("[WatchSessionManager] Token sent to Watch")
}
private func handleSendWidgetDataToWatch(arguments: Any?, result: @escaping FlutterResult) {
@@ -237,22 +286,33 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return
}
guard WCSession.default.activationState == .activated else {
result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil))
guard WCSession.default.isWatchAppInstalled else {
print("[WatchSessionManager] No paired Watch app, skipping language publish")
result(nil)
return
}
do {
try WCSession.default.updateApplicationContext(["language": languageCode])
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
} catch {
print("[WatchSessionManager] Failed to update applicationContext for language: \(error)")
}
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode)
WCSession.default.transferUserInfo([
"id": "language_update",
"language": languageCode
])
if WCSession.default.activationState == .activated {
do {
try WCSession.default.updateApplicationContext([
"language": languageCode,
"language_state_version": sharedState.stateVersion
])
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
} catch {
print("[WatchSessionManager] Failed to update applicationContext for language: \(error)")
}
WCSession.default.transferUserInfo([
"id": "language_update",
"language": languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] WCSession not active, language shared-state published only")
}
result(nil)
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch")
}
@@ -325,6 +385,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return
}
guard WCSession.default.isWatchAppInstalled else {
print("[WatchSessionManager] No paired Watch app, skipping token save to shared Keychain")
result(nil)
return
}
guard let accessToken = tokenData["accessToken"] as? String,
let refreshToken = tokenData["refreshToken"] as? String,
let idToken = tokenData["idToken"] as? String,
@@ -352,7 +418,26 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
updatedAtMs: updatedAtMs
)
SharedKeychainManager.shared.saveToken(token)
let forceAccountSwitch = (tokenData["forceAccountSwitch"] as? Bool) == true
let didSave = SharedKeychainManager.shared.saveToken(
token,
forceAccountSwitch: forceAccountSwitch
)
if WCSession.default.isWatchAppInstalled {
if didSave {
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: true,
activeStudentIdNorm: studentIdNorm
)
} else if SharedKeychainManager.shared.loadToken()?.studentIdNorm == studentIdNorm {
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: true,
activeStudentIdNorm: studentIdNorm
)
} else {
print("[WatchSessionManager] Token save skipped (stale/cross-account); skipping session-state publish for \(studentIdNorm)")
}
}
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
@@ -372,10 +457,136 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
result(installed)
}
private func handleIsWatchReachable(result: @escaping FlutterResult) {
guard WCSession.isSupported() else {
result(false)
return
}
let session = WCSession.default
let reachable =
session.isPaired &&
session.isWatchAppInstalled &&
session.activationState == .activated &&
session.isReachable
result(reachable)
}
private func handleClearICloudToken(result: @escaping FlutterResult) {
SharedKeychainManager.shared.deleteToken()
SharedKeychainManager.shared.clearKVStore()
RefreshLeaseManager.shared.clearAllLeases()
SharedLanguageStateManager.shared.clearState()
if WCSession.default.isWatchAppInstalled {
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: false,
activeStudentIdNorm: nil
)
} else {
SharedSessionStateManager.shared.clearState()
}
result(nil)
}
private func handleWaitForPeerRefreshLease(arguments: Any?, result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any],
let owner = parseLeaseOwner(args["owner"]),
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid wait lease arguments", details: nil))
return
}
let maxWaitMs = parseInt64(args["maxWaitMs"]) ?? 150_000
let pollMs = parseInt64(args["pollIntervalMs"]) ?? 250
if owner == .iphone && !WCSession.default.isWatchAppInstalled {
result([
"ready": true,
"status": "no_watch",
"waitedMs": 0,
"leaseChanged": false
])
return
}
Task {
let waitResult = await RefreshLeaseManager.shared.waitForPeerLeaseRelease(
owner: owner,
studentIdNorm: studentIdNorm,
maxWaitMs: maxWaitMs,
pollIntervalMs: pollMs
)
DispatchQueue.main.async {
result(waitResult.asDictionary())
}
}
}
private func handleAcquireRefreshLease(arguments: Any?, result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any],
let owner = parseLeaseOwner(args["owner"]),
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid acquire lease arguments", details: nil))
return
}
if owner == .iphone && !WCSession.default.isWatchAppInstalled {
result([
"skipped": true,
"status": "no_watch"
])
return
}
let ttlMs = parseInt64(args["ttlMs"]) ?? 120_000
let operationId = (args["operationId"] as? String) ?? UUID().uuidString
let lease = RefreshLeaseManager.shared.acquireLease(
owner: owner,
studentIdNorm: studentIdNorm,
ttlMs: ttlMs,
operationId: operationId
)
result([
"operationId": lease.operationId,
"startedAtMs": lease.startedAtMs,
"expiresAtMs": lease.expiresAtMs
])
}
private func handleReleaseRefreshLease(arguments: Any?, result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any],
let owner = parseLeaseOwner(args["owner"]),
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid release lease arguments", details: nil))
return
}
let operationId = args["operationId"] as? String
RefreshLeaseManager.shared.releaseLease(
owner: owner,
studentIdNorm: studentIdNorm,
operationId: operationId
)
result(nil)
}
private func handleClearRefreshLeaseForAccount(arguments: Any?, result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any],
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing studentIdNorm", details: nil))
return
}
RefreshLeaseManager.shared.clearLeases(studentIdNorm: studentIdNorm)
result(nil)
}
private func handleClearAllRefreshLeases(result: @escaping FlutterResult) {
RefreshLeaseManager.shared.clearAllLeases()
result(nil)
}
private func handleClearSharedLanguageState(result: @escaping FlutterResult) {
SharedLanguageStateManager.shared.clearState()
result(nil)
}
@@ -498,11 +709,19 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
DispatchQueue.main.async {
self.flutterChannel?.invokeMethod("getLanguageForWatch", arguments: nil) { result in
if let languageCode = result as? String {
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode)
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
replyHandler(["language": languageCode])
replyHandler([
"language": languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: "hu")
print("[WatchSessionManager] No language from Flutter, defaulting to hu")
replyHandler(["language": "hu"])
replyHandler([
"language": "hu",
"language_state_version": sharedState.stateVersion
])
}
}
}

View File

@@ -20,6 +20,10 @@ class SharedKeychainManager {
private init() {}
var resolvedAccessGroup: String {
accessGroup
}
private func resolveAccessGroup() -> String {
let probeService = "\(service).probe"
let probeAccount = "probe"
@@ -279,3 +283,520 @@ class SharedKeychainManager {
print("[SharedKeychain] Cleared old KV Store data")
}
}
enum RefreshLeaseOwner: String {
case iphone
case watch
var peer: RefreshLeaseOwner {
switch self {
case .iphone:
return .watch
case .watch:
return .iphone
}
}
}
struct SharedSessionStateRecord: Codable {
let stateVersion: Int64
let hasAnyAccount: Bool
let activeStudentIdNorm: Int64?
let updatedAtMs: Int64
let sourceDevice: String
}
struct SharedLanguageStateRecord: Codable {
let stateVersion: Int64
let languageCode: String
let updatedAtMs: Int64
let expiresAtMs: Int64
let sourceDevice: String
}
class SharedLanguageStateManager {
static let shared = SharedLanguageStateManager()
private let service = "app.firka.shared.language_state"
private let account = "language_state"
private let accessGroup: String
private let maxTtlMs: Int64 = 7 * 24 * 60 * 60 * 1000
#if os(iOS)
private let sourceDevice = "iphone"
#elseif os(watchOS)
private let sourceDevice = "watch"
#endif
private init() {
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
}
private func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func encode(_ state: SharedLanguageStateRecord) -> Data? {
try? JSONEncoder().encode(state)
}
private func decode(_ data: Data) -> SharedLanguageStateRecord? {
try? JSONDecoder().decode(SharedLanguageStateRecord.self, from: data)
}
private func keychainQueryBase() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup
]
}
private func loadStateFromKeychain() -> SharedLanguageStateRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return decode(data)
}
private func storeStateInKeychain(_ state: SharedLanguageStateRecord) {
guard let data = encode(state) else {
print("[SharedLanguageState] Failed to encode state for keychain")
return
}
var deleteQuery = keychainQueryBase()
deleteQuery[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
SecItemDelete(deleteQuery as CFDictionary)
var addQuery = keychainQueryBase()
addQuery[kSecAttrSynchronizable as String] = kCFBooleanTrue!
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
addQuery[kSecValueData as String] = data
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
print("[SharedLanguageState] Failed to publish state to keychain: \(status)")
}
}
private func clearKeychainState() {
var query = keychainQueryBase()
query[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
SecItemDelete(query as CFDictionary)
}
private func isExpired(_ state: SharedLanguageStateRecord, now: Int64) -> Bool {
state.expiresAtMs <= now
}
func loadState() -> SharedLanguageStateRecord? {
let now = nowMs()
guard let keychainState = loadStateFromKeychain() else {
return nil
}
if isExpired(keychainState, now: now) {
clearKeychainState()
return nil
}
return keychainState
}
@discardableResult
func publishState(
languageCode: String,
ttlMs: Int64 = 24 * 60 * 60 * 1000
) -> SharedLanguageStateRecord {
let now = nowMs()
let previousVersion = loadStateFromKeychain()?.stateVersion ?? 0
let nextVersion = max(now, previousVersion + 1)
let effectiveTtl = max(min(ttlMs, maxTtlMs), 60_000)
let state = SharedLanguageStateRecord(
stateVersion: nextVersion,
languageCode: languageCode,
updatedAtMs: now,
expiresAtMs: now + effectiveTtl,
sourceDevice: sourceDevice
)
storeStateInKeychain(state)
return state
}
func clearState() {
clearKeychainState()
}
}
class SharedSessionStateManager {
static let shared = SharedSessionStateManager()
private let service = "app.firka.shared.session_state"
private let account = "session_state"
private let accessGroup: String
#if os(iOS)
private let sourceDevice = "iphone"
#elseif os(watchOS)
private let sourceDevice = "watch"
#endif
private init() {
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
}
private func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func encode(_ state: SharedSessionStateRecord) -> Data? {
try? JSONEncoder().encode(state)
}
private func decode(_ data: Data) -> SharedSessionStateRecord? {
try? JSONDecoder().decode(SharedSessionStateRecord.self, from: data)
}
func loadState() -> SharedSessionStateRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return decode(data)
}
@discardableResult
func publishState(
hasAnyAccount: Bool,
activeStudentIdNorm: Int64?
) -> SharedSessionStateRecord {
let now = nowMs()
let previousVersion = loadState()?.stateVersion ?? 0
let nextVersion = max(now, previousVersion + 1)
let state = SharedSessionStateRecord(
stateVersion: nextVersion,
hasAnyAccount: hasAnyAccount,
activeStudentIdNorm: hasAnyAccount ? activeStudentIdNorm : nil,
updatedAtMs: now,
sourceDevice: sourceDevice
)
if let data = encode(state) {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
print("[SharedSessionState] Failed to publish state: \(status)")
}
} else {
print("[SharedSessionState] Failed to encode state")
}
return state
}
func clearState() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(query as CFDictionary)
}
}
struct RefreshLeaseRecord: Codable {
let owner: String
let studentIdNorm: Int64
let operationId: String
let startedAtMs: Int64
let expiresAtMs: Int64
}
struct RefreshLeaseWaitResult {
let ready: Bool
let status: String
let waitedMs: Int64
let peerOperationId: String?
let leaseChanged: Bool
func asDictionary() -> [String: Any] {
var dict: [String: Any] = [
"ready": ready,
"status": status,
"waitedMs": waitedMs,
"leaseChanged": leaseChanged
]
if let peerOperationId {
dict["peerOperationId"] = peerOperationId
}
return dict
}
}
class RefreshLeaseManager {
static let shared = RefreshLeaseManager()
private let service = "app.firka.shared.refresh_lease"
private let accountPrefix = "lease"
private let accessGroup: String
private init() {
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
}
private func keyAccount(
owner: RefreshLeaseOwner,
studentIdNorm: Int64
) -> String {
"\(accountPrefix)_\(owner.rawValue)_\(studentIdNorm)"
}
private func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func encode(_ lease: RefreshLeaseRecord) -> Data? {
try? JSONEncoder().encode(lease)
}
private func decode(_ data: Data) -> RefreshLeaseRecord? {
try? JSONDecoder().decode(RefreshLeaseRecord.self, from: data)
}
func loadLease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64
) -> RefreshLeaseRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm),
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return decode(data)
}
@discardableResult
func acquireLease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64,
ttlMs: Int64,
operationId: String = UUID().uuidString
) -> RefreshLeaseRecord {
let now = nowMs()
let clampedTtl = max(ttlMs, 5_000)
let lease = RefreshLeaseRecord(
owner: owner.rawValue,
studentIdNorm: studentIdNorm,
operationId: operationId,
startedAtMs: now,
expiresAtMs: now + clampedTtl
)
if let data = encode(lease) {
let account = keyAccount(owner: owner, studentIdNorm: studentIdNorm)
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
print("[RefreshLease] Failed to acquire lease for \(owner.rawValue): \(status)")
}
} else {
print("[RefreshLease] Failed to encode lease for \(owner.rawValue)")
}
return lease
}
func releaseLease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64,
operationId: String? = nil
) {
if let operationId,
let current = loadLease(owner: owner, studentIdNorm: studentIdNorm),
current.operationId != operationId {
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm),
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(query as CFDictionary)
}
func clearLeases(studentIdNorm: Int64) {
releaseLease(owner: .iphone, studentIdNorm: studentIdNorm, operationId: nil)
releaseLease(owner: .watch, studentIdNorm: studentIdNorm, operationId: nil)
}
func clearAllLeases() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status != errSecSuccess {
return
}
guard let items = result as? [[String: Any]] else {
return
}
for item in items {
guard let account = item[kSecAttrAccount as String] as? String else {
continue
}
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
}
}
func waitForPeerLeaseRelease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64,
maxWaitMs: Int64,
pollIntervalMs: Int64
) async -> RefreshLeaseWaitResult {
let startedAt = nowMs()
var deadline = startedAt + max(maxWaitMs, 1_000)
var lastFingerprint: String?
var leaseChanged = false
while nowMs() < deadline {
let now = nowMs()
guard let peer = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm) else {
return RefreshLeaseWaitResult(
ready: true,
status: leaseChanged ? "peer_lease_changed" : "peer_lease_missing",
waitedMs: now - startedAt,
peerOperationId: nil,
leaseChanged: leaseChanged
)
}
if peer.expiresAtMs <= now {
releaseLease(
owner: owner.peer,
studentIdNorm: studentIdNorm,
operationId: peer.operationId
)
return RefreshLeaseWaitResult(
ready: true,
status: "peer_lease_expired",
waitedMs: now - startedAt,
peerOperationId: peer.operationId,
leaseChanged: leaseChanged
)
}
let fingerprint = "\(peer.operationId)|\(peer.startedAtMs)|\(peer.expiresAtMs)"
if let previousFingerprint = lastFingerprint, previousFingerprint != fingerprint {
leaseChanged = true
deadline = min(deadline, peer.expiresAtMs + 5_000)
lastFingerprint = fingerprint
continue
}
lastFingerprint = fingerprint
deadline = min(deadline, peer.expiresAtMs + 5_000)
let sleepMs = max(min(pollIntervalMs, 1_000), 50)
try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
}
let waited = max(nowMs() - startedAt, 0)
let peerOperation = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm)?.operationId
return RefreshLeaseWaitResult(
ready: false,
status: "timed_out",
waitedMs: waited,
peerOperationId: peerOperation,
leaseChanged: leaseChanged
)
}
}

View File

@@ -44,6 +44,13 @@ class TokenManager {
private let proactiveRefreshLeadTime: TimeInterval = 5 * 60
private let minimumProactiveRefreshInterval: TimeInterval = 60
private let iCloudProbeTimeoutNs: UInt64 = 1_500_000_000
private let refreshRequestTimeout: TimeInterval = 12
private let refreshResourceTimeout: TimeInterval = 20
#if os(watchOS)
private let watchRefreshLeaseTtlMs: Int64 = 180_000
private let iPhoneRefreshLeaseMaxWaitMs: Int64 = 150_000
private let refreshLeasePollIntervalMs: Int64 = 250
#endif
#if os(iOS)
private let deviceName = "iPhone"
@@ -84,6 +91,41 @@ class TokenManager {
lastRecoveryFailure = nil
}
#if os(watchOS)
private func withWatchRefreshLease<T>(
studentIdNorm: Int64,
_ operation: () async throws -> T
) async throws -> T {
let waitResult = await RefreshLeaseManager.shared.waitForPeerLeaseRelease(
owner: .watch,
studentIdNorm: studentIdNorm,
maxWaitMs: iPhoneRefreshLeaseMaxWaitMs,
pollIntervalMs: refreshLeasePollIntervalMs
)
guard waitResult.ready else {
print("[TokenManager] Watch refresh lease wait timed out (waited \(waitResult.waitedMs)ms, changed: \(waitResult.leaseChanged))")
throw TokenError.networkError
}
let lease = RefreshLeaseManager.shared.acquireLease(
owner: .watch,
studentIdNorm: studentIdNorm,
ttlMs: watchRefreshLeaseTtlMs
)
defer {
RefreshLeaseManager.shared.releaseLease(
owner: .watch,
studentIdNorm: studentIdNorm,
operationId: lease.operationId
)
}
return try await operation()
}
#endif
private func getActiveStudentIdNorm() -> Int64? {
if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Int64 {
return value
@@ -263,7 +305,24 @@ class TokenManager {
return nil
}
let preferredStudentIdNorm = getActiveStudentIdNorm()
var preferredStudentIdNorm = getActiveStudentIdNorm()
var requirePreferredAccount = false
#if os(watchOS)
if let sessionState = SharedSessionStateManager.shared.loadState() {
if !sessionState.hasAnyAccount {
print("[TokenManager] Shared session state indicates no active accounts, returning no token")
return nil
}
if let sharedActiveStudentIdNorm = sessionState.activeStudentIdNorm {
preferredStudentIdNorm = sharedActiveStudentIdNorm
requirePreferredAccount = true
if getActiveStudentIdNorm() != sharedActiveStudentIdNorm {
setActiveStudentIdNorm(sharedActiveStudentIdNorm)
}
}
}
#endif
let freshest: (token: WatchToken, source: String)
if let preferredStudentIdNorm {
let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm }
@@ -273,7 +332,15 @@ class TokenManager {
} {
freshest = preferredFreshest
} else {
print("[TokenManager] Active account token not found locally, falling back to freshest available account")
if requirePreferredAccount {
print("[TokenManager] Active shared-session account token (\(preferredStudentIdNorm)) not found yet, falling back to best available token")
#if os(watchOS)
if WCSession.default.activationState == .activated && WCSession.default.isReachable {
print("[TokenManager] iPhone reachable, requesting active account token")
WatchConnectivityManager.shared.requestTokenFromPhone()
}
#endif
}
freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
}
@@ -326,6 +393,11 @@ class TokenManager {
// MARK: - Delete Token
func deleteToken() {
print("[TokenManager] Deleting token from all storage locations")
if let previousToken = loadToken() {
RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm)
} else {
RefreshLeaseManager.shared.clearAllLeases()
}
deleteTokenFromKeychain()
SharedKeychainManager.shared.deleteToken()
UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey)
@@ -340,7 +412,8 @@ class TokenManager {
syncToSharedKeychain: Bool = false,
forceAccountSwitch: Bool = false
) throws {
if let currentToken = loadToken() {
let currentToken = loadToken()
if let currentToken {
if forceAccountSwitch && !token.isSameAccount(as: currentToken) {
print("[TokenManager] Forcing token save for explicit account switch (\(currentToken.studentIdNorm) -> \(token.studentIdNorm))")
} else if !token.isNewer(than: currentToken) {
@@ -349,6 +422,12 @@ class TokenManager {
}
}
if forceAccountSwitch,
let currentToken,
!token.isSameAccount(as: currentToken) {
RefreshLeaseManager.shared.clearLeases(studentIdNorm: currentToken.studentIdNorm)
}
print("[TokenManager] Saving token locally (Keychain + file)")
setActiveStudentIdNorm(token.studentIdNorm)
@@ -809,6 +888,32 @@ class TokenManager {
#endif
private func refreshTokenInternal(_ token: WatchToken) async throws -> WatchToken {
#if os(watchOS)
return try await withWatchRefreshLease(studentIdNorm: token.studentIdNorm) {
let response = try await performTokenRefresh(
refreshToken: token.refreshToken,
instituteCode: token.iss
)
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs
let newToken = WatchToken(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
idToken: response.idToken,
iss: token.iss,
studentId: token.studentId,
studentIdNorm: token.studentIdNorm,
expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60),
tokenVersion: tokenVersion,
updatedAtMs: nowMs
)
try saveToken(newToken, syncToSharedKeychain: true)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
return newToken
}
#else
let response = try await performTokenRefresh(
refreshToken: token.refreshToken,
instituteCode: token.iss
@@ -829,12 +934,8 @@ class TokenManager {
)
try saveToken(newToken, syncToSharedKeychain: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
#endif
return newToken
#endif
}
// MARK: - Refresh Token
@@ -843,6 +944,32 @@ class TokenManager {
throw TokenError.noToken
}
#if os(watchOS)
return try await withWatchRefreshLease(studentIdNorm: currentToken.studentIdNorm) {
let response = try await performTokenRefresh(
refreshToken: currentToken.refreshToken,
instituteCode: currentToken.iss
)
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs
let newToken = WatchToken(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
idToken: response.idToken,
iss: currentToken.iss,
studentId: currentToken.studentId,
studentIdNorm: currentToken.studentIdNorm,
expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60),
tokenVersion: tokenVersion,
updatedAtMs: nowMs
)
try saveToken(newToken, syncToSharedKeychain: true)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
return newToken
}
#else
let response = try await performTokenRefresh(
refreshToken: currentToken.refreshToken,
instituteCode: currentToken.iss
@@ -863,12 +990,8 @@ class TokenManager {
)
try saveToken(newToken, syncToSharedKeychain: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
#endif
return newToken
#endif
}
// MARK: - Private Helper Methods
@@ -885,6 +1008,7 @@ class TokenManager {
request.setValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.timeoutInterval = refreshRequestTimeout
let formParameters: [String: String] = [
"institute_code": instituteCode,
@@ -896,7 +1020,11 @@ class TokenManager {
request.httpBody = encodeFormData(formParameters).data(using: .utf8)
do {
let (data, response) = try await URLSession.shared.data(for: request)
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = refreshRequestTimeout
configuration.timeoutIntervalForResource = refreshResourceTimeout
let session = URLSession(configuration: configuration)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TokenError.networkError

View File

@@ -84,6 +84,44 @@ class KretaClient {
KretaClient(this.model, this.isar);
Future<TokenModel> _refreshModelWithCrossDeviceLease(
TokenModel sourceToken) async {
final studentIdNorm = sourceToken.studentIdNorm;
String? leaseOperationId;
try {
if (Platform.isIOS && studentIdNorm != null) {
final watchInstalled = await WatchSyncHelper.isWatchAppInstalled();
if (watchInstalled) {
final leaseReady = await WatchSyncHelper.waitForWatchRefreshLease(
studentIdNorm: studentIdNorm,
);
if (!leaseReady) {
throw Exception('watch_refresh_lease_timeout');
}
leaseOperationId = await WatchSyncHelper.acquireIPhoneRefreshLease(
studentIdNorm: studentIdNorm,
);
if (leaseOperationId == null) {
throw Exception('iphone_refresh_lease_acquire_failed');
}
}
}
final extended = await extendToken(sourceToken);
return TokenModel.fromResp(extended);
} finally {
if (Platform.isIOS &&
studentIdNorm != null &&
leaseOperationId != null) {
await WatchSyncHelper.releaseIPhoneRefreshLease(
studentIdNorm: studentIdNorm,
operationId: leaseOperationId,
);
}
}
}
Future<void> _syncTokenToAppleTargets(TokenModel token) async {
if (!Platform.isIOS) return;
if (token.accessToken == null ||
@@ -155,8 +193,7 @@ class KretaClient {
logger.info("[Recovery] Step 1: Trying local token refresh...");
try {
var extended = await extendToken(model);
var tokenModel = TokenModel.fromResp(extended);
var tokenModel = await _refreshModelWithCrossDeviceLease(model);
await isar.writeTxn(() async {
await isar.tokenModels.put(tokenModel);
@@ -222,8 +259,7 @@ class KretaClient {
logger.info(
"[Recovery] Found iCloud token close to expiry, trying refresh...");
try {
var extended = await extendToken(model);
var tokenModel = TokenModel.fromResp(extended);
var tokenModel = await _refreshModelWithCrossDeviceLease(model);
await isar.writeTxn(() async {
await isar.tokenModels.put(tokenModel);

View File

@@ -377,21 +377,6 @@ class LiveActivityService {
}
}
/// Check if there are any remaining lessons today
static bool _hasRemainingLessonsToday(List<Lesson> lessons) {
final now = DateTime.now();
final todayLessons = lessons.where((lesson) {
final uid = lesson.uid.toLowerCase();
return lesson.date == now.toIso8601String().split('T').first &&
lesson.end.isAfter(now) &&
(uid.contains('orarendiora') ||
uid.contains('tanitasiora') ||
uid.contains('uresora'));
}).toList();
return todayLessons.isNotEmpty;
}
/// Perform background fetch - fetch fresh timetable from KRÉTA API and send to backend
/// This is called by iOS BGTaskScheduler when the app is in background
static Future<bool> _performBackgroundFetch() async {
@@ -538,14 +523,7 @@ class LiveActivityService {
if (success) {
await _saveLastUpdate();
_logger.info('Background fetch: successfully sent timetable to backend');
if (!_hasRemainingLessonsToday(allLessons)) {
_logger.info('Background fetch: no remaining lessons today, cancelling future background fetches until app reopens');
await cancelBackgroundFetch();
} else {
_logger.info('Background fetch: remaining lessons today, will continue background fetches');
}
_logger.info('Background fetch: keeping periodic scheduling active');
return true;
} else {
_logger.warning('Background fetch: failed to send timetable to backend');

View File

@@ -15,11 +15,15 @@ import 'db/models/token_model.dart';
/// Helper class for Watch ↔ iPhone token sync
class WatchSyncHelper {
static const _watchChannel = MethodChannel('app.firka/watch_sync');
static const _leaseOwnerIPhone = 'iphone';
static bool _initialized = false;
static bool _watchAppInstalledCache = false;
static DateTime? _lastWatchInstallCheckAt;
static const Duration _watchInstallCheckCooldown = Duration(seconds: 10);
static const Duration _tokenUsableSkew = Duration(seconds: 60);
static const Duration _leasePollInterval = Duration(milliseconds: 250);
static const Duration _iPhoneRefreshLeaseTtl = Duration(seconds: 120);
static const Duration _watchRefreshLeaseMaxWait = Duration(seconds: 150);
static const String _iosFreshInstallHandledKey =
'ios_fresh_install_cleanup_done_v1';
@@ -258,6 +262,21 @@ class WatchSyncHelper {
return _watchAppInstalledCache;
}
static Future<bool> isWatchReachable({bool forceRefreshInstall = false}) async {
if (!Platform.isIOS) return false;
final watchInstalled =
await isWatchAppInstalled(forceRefresh: forceRefreshInstall);
if (!watchInstalled) return false;
final result = await _invokeMethodWithTimeout<bool>(
'isWatchReachable',
null,
const Duration(seconds: 2),
);
return result == true;
}
static Future<void> clearICloudToken({bool notifyWatch = false}) async {
if (!Platform.isIOS) return;
await _invokeMethodWithTimeout(
@@ -281,6 +300,124 @@ class WatchSyncHelper {
);
}
static Future<bool> waitForWatchRefreshLease({
required int studentIdNorm,
Duration maxWait = _watchRefreshLeaseMaxWait,
}) async {
if (!Platform.isIOS) return true;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) return true;
final timeout = maxWait + const Duration(seconds: 5);
final result = await _invokeMethodWithTimeout<dynamic>(
'waitForPeerRefreshLease',
{
'owner': _leaseOwnerIPhone,
'studentIdNorm': studentIdNorm,
'maxWaitMs': maxWait.inMilliseconds,
'pollIntervalMs': _leasePollInterval.inMilliseconds,
},
timeout,
);
if (result is! Map) {
debugPrint('[WatchSync] Lease wait returned invalid response: $result');
return true;
}
final ready = result['ready'] == true;
final status = result['status'];
final waitedMs = result['waitedMs'];
final leaseChanged = result['leaseChanged'] == true;
debugPrint(
'[WatchSync] Lease wait status=$status ready=$ready waitedMs=$waitedMs leaseChanged=$leaseChanged');
return ready;
}
static Future<String?> acquireIPhoneRefreshLease({
required int studentIdNorm,
Duration ttl = _iPhoneRefreshLeaseTtl,
}) async {
if (!Platform.isIOS) return null;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) return null;
final result = await _invokeMethodWithTimeout<dynamic>(
'acquireRefreshLease',
{
'owner': _leaseOwnerIPhone,
'studentIdNorm': studentIdNorm,
'ttlMs': ttl.inMilliseconds,
},
const Duration(seconds: 5),
);
if (result is! Map) {
debugPrint('[WatchSync] Lease acquire returned invalid response: $result');
return null;
}
if (result['skipped'] == true) {
return null;
}
final operationId = result['operationId'] as String?;
if (operationId == null || operationId.isEmpty) {
debugPrint('[WatchSync] Lease acquire response missing operationId');
return null;
}
return operationId;
}
static Future<void> releaseIPhoneRefreshLease({
required int studentIdNorm,
required String operationId,
}) async {
if (!Platform.isIOS) return;
await _invokeMethodWithTimeout(
'releaseRefreshLease',
{
'owner': _leaseOwnerIPhone,
'studentIdNorm': studentIdNorm,
'operationId': operationId,
},
const Duration(seconds: 5),
);
}
static Future<void> clearRefreshLeaseForAccount(int studentIdNorm) async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) return;
await _invokeMethodWithTimeout(
'clearRefreshLeaseForAccount',
{
'studentIdNorm': studentIdNorm,
},
const Duration(seconds: 5),
);
}
static Future<void> clearAllRefreshLeases() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) return;
await _invokeMethodWithTimeout(
'clearAllRefreshLeases',
null,
const Duration(seconds: 5),
);
}
static Future<void> clearSharedLanguageState() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) return;
await _invokeMethodWithTimeout(
'clearSharedLanguageState',
null,
const Duration(seconds: 5),
);
}
static Future<bool> runFreshInstallCleanupIfNeeded({
required Isar isar,
}) async {
@@ -295,6 +432,7 @@ class WatchSyncHelper {
debugPrint(
'[WatchSync] Fresh iOS install detected, clearing iCloud and local auth state');
await clearICloudToken(notifyWatch: true);
await clearAllRefreshLeases();
await isar.writeTxn(() async {
await isar.tokenModels.clear();
@@ -391,17 +529,16 @@ class WatchSyncHelper {
return {'error': 'token_incomplete'};
}
if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) {
debugPrint(
'[WatchSync] Active iPhone token is expired, not sending to Watch');
return {'error': 'needsReauth'};
}
if (KretaClient.needsReauth) {
debugPrint('[WatchSync] iPhone needs reauth');
return {'error': 'needsReauth'};
}
if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) {
debugPrint(
'[WatchSync] Active iPhone token access is expired, forwarding token to Watch for recovery');
}
final tokenData = _buildTokenSyncPayload(token, includeSentAt: true);
debugPrint('[WatchSync] Returning token for Watch');
@@ -410,6 +547,8 @@ class WatchSyncHelper {
static Future<void> sendTokenToWatch() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) return;
final tokenData = _getTokenForWatch();
if (tokenData == null) return;
@@ -420,9 +559,15 @@ class WatchSyncHelper {
/// Sends a specific token directly to Watch.
/// Useful during app initialization before global init state is fully ready.
static Future<void> sendTokenModelToWatch(TokenModel token) async {
static Future<void> sendTokenModelToWatch(
TokenModel token, {
bool allowExpiredAccessToken = false,
}) async {
if (!Platform.isIOS) return;
await _sendTokenToWatchInternal(token);
await _sendTokenToWatchInternal(
token,
allowExpiredAccessToken: allowExpiredAccessToken,
);
}
static Future<Map<String, dynamic>> _processTokenFromWatch(
@@ -522,8 +667,16 @@ class WatchSyncHelper {
}
}
static Future<void> _sendTokenToWatchInternal(TokenModel token) async {
static Future<void> _sendTokenToWatchInternal(
TokenModel token, {
bool allowExpiredAccessToken = false,
}) async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) {
debugPrint('[WatchSync] No paired Watch app, skipping token send');
return;
}
if (token.accessToken == null ||
token.refreshToken == null ||
@@ -532,10 +685,16 @@ class WatchSyncHelper {
return;
}
if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) {
final accessExpired =
!_isAccessTokenUsable(token.expiryDate, skew: const Duration());
if (accessExpired && !allowExpiredAccessToken) {
debugPrint('[WatchSync] Token expired, not sending to Watch');
return;
}
if (accessExpired && allowExpiredAccessToken) {
debugPrint(
'[WatchSync] Sending expired-access token to Watch for account-switch recovery');
}
final tokenData = _buildTokenSyncPayload(token, includeSentAt: true);
@@ -556,6 +715,11 @@ class WatchSyncHelper {
static Future<void> sendLanguageToWatch() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) {
debugPrint('[WatchSync] No paired Watch app, skipping language publish');
return;
}
final languageCode = _getLanguageForWatch();
if (languageCode == null) return;
@@ -575,6 +739,10 @@ class WatchSyncHelper {
bool allowExpiredAccessToken = false,
}) async {
if (!Platform.isIOS) return false;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) {
return false;
}
final effectiveIsar = isar ?? (initDone ? initData.isar : null);
final effectiveTokens = tokens ?? (initDone ? initData.tokens : null);
@@ -705,7 +873,10 @@ class WatchSyncHelper {
}
/// Save token to iCloud. Call this after refreshing token on iPhone.
static Future<void> saveTokenToiCloud(TokenModel token) async {
static Future<void> saveTokenToiCloud(
TokenModel token, {
bool forceAccountSwitch = false,
}) async {
if (!Platform.isIOS) return;
if (token.accessToken == null ||
@@ -723,6 +894,7 @@ class WatchSyncHelper {
}
final tokenData = _buildTokenSyncPayload(token);
tokenData['forceAccountSwitch'] = forceAccountSwitch;
await _invokeMethodWithTimeout(
'saveTokeToniCloud', tokenData, const Duration(seconds: 5));
@@ -766,7 +938,10 @@ class WatchSyncHelper {
currentToken.expiryDate != null &&
!KretaClient.needsReauth) {
debugPrint('[WatchSync] Sending iPhone token to Watch (no response)');
await _sendTokenToWatchInternal(currentToken);
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
return;
}
@@ -781,7 +956,10 @@ class WatchSyncHelper {
!KretaClient.needsReauth) {
debugPrint(
'[WatchSync] Sending iPhone token to Watch (Watch has no token)');
await _sendTokenToWatchInternal(currentToken);
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
return;
}
@@ -807,7 +985,10 @@ class WatchSyncHelper {
currentToken.refreshToken != null &&
currentToken.expiryDate != null &&
!KretaClient.needsReauth) {
await _sendTokenToWatchInternal(currentToken);
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
return;
}
@@ -823,7 +1004,10 @@ class WatchSyncHelper {
_isAccessTokenUsable(currentToken.expiryDate,
skew: const Duration()) &&
!KretaClient.needsReauth) {
await _sendTokenToWatchInternal(currentToken);
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
return;
}
@@ -882,7 +1066,10 @@ class WatchSyncHelper {
} else {
debugPrint(
'[WatchSync] iPhone token is same or newer, sending to Watch');
await _sendTokenToWatchInternal(currentToken);
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
} catch (e) {
debugPrint('[WatchSync] Failed to sync token from Watch: $e');

View File

@@ -265,6 +265,12 @@ Future<void> _initData(AppInitialization init) async {
}
unawaited(() async {
try {
await WatchSyncHelper.saveTokenToiCloud(token);
} catch (e) {
logger.warning('[Init] Failed to sync active token to iCloud: $e');
}
try {
await WatchSyncHelper.sendTokenModelToWatch(token);
} catch (e) {

View File

@@ -662,8 +662,24 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
),
onTap: () async {
if (i != item.accountIndex) {
final previousAccountId = widget.data.client.model.studentIdNorm;
if (Platform.isIOS) {
await LiveActivityService.onUserLogout();
try {
await WatchSyncHelper.clearSharedLanguageState();
} catch (e) {
logger.warning(
'[Settings] Failed to clear shared language state on account switch: $e');
}
if (previousAccountId != null) {
try {
await WatchSyncHelper.clearRefreshLeaseForAccount(
previousAccountId);
} catch (e) {
logger.warning(
'[Settings] Failed to clear refresh lease on account switch: $e');
}
}
}
await widget.data.isar.writeTxn(() async {
@@ -674,6 +690,40 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
await item.postUpdate();
if (Platform.isIOS) {
var watchReachable = false;
try {
watchReachable = await WatchSyncHelper.isWatchReachable(
forceRefreshInstall: true,
);
} catch (e) {
logger.warning(
'[Settings] Failed to query Watch reachability on account switch: $e');
}
if (watchReachable) {
try {
await WatchSyncHelper.sendTokenModelToWatch(
token,
allowExpiredAccessToken: true,
);
} catch (e) {
logger.warning(
'[Settings] Failed to send switched account token to reachable Watch: $e');
}
} else {
try {
await WatchSyncHelper.saveTokenToiCloud(
token,
forceAccountSwitch: true,
);
} catch (e) {
logger.warning(
'[Settings] Failed to sync switched account token to iCloud: $e');
}
}
}
runApp(InitializationScreen());
}
},
@@ -725,6 +775,20 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
}
final active = widget.data.client.model.studentIdNorm!;
if (Platform.isIOS) {
try {
await WatchSyncHelper.clearRefreshLeaseForAccount(active);
} catch (e) {
logger.warning(
'[Settings] Failed to clear refresh lease for active account: $e');
}
try {
await WatchSyncHelper.clearSharedLanguageState();
} catch (e) {
logger.warning(
'[Settings] Failed to clear shared language state on logout: $e');
}
}
await widget.data.isar.writeTxn(() async {
await widget.data.isar.tokenModels.delete(active);
@@ -740,6 +804,7 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
if (Platform.isIOS) {
try {
await WatchSyncHelper.clearICloudToken(notifyWatch: true);
await WatchSyncHelper.clearAllRefreshLeases();
} catch (e) {
logger.warning('[Settings] Failed to clear iCloud token: $e');
}
@@ -752,6 +817,41 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
(route) => false,
);
} else {
if (Platform.isIOS) {
final nextToken = accounts.first;
var watchReachable = false;
try {
watchReachable = await WatchSyncHelper.isWatchReachable(
forceRefreshInstall: true,
);
} catch (e) {
logger.warning(
'[Settings] Failed to query Watch reachability after logout: $e');
}
if (watchReachable) {
try {
await WatchSyncHelper.sendTokenModelToWatch(
nextToken,
allowExpiredAccessToken: true,
);
} catch (e) {
logger.warning(
'[Settings] Failed to send next account token to reachable Watch after logout: $e');
}
} else {
try {
await WatchSyncHelper.saveTokenToiCloud(
nextToken,
forceAccountSwitch: true,
);
} catch (e) {
logger.warning(
'[Settings] Failed to sync next account token to iCloud after logout: $e');
}
}
}
widget.data.tokens = accounts;
runApp(InitializationScreen());
}