forked from firka/firka
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:
@@ -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?()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user