forked from firka/firka
Multiple fixes and improvements for watch <> phone sync, token recovery, and live activity behavior: - WatchSessionManager: add mergeApplicationContext to avoid clobbering app context, add thread-safe pending auth queue and flush, add sendMessageToWatch API, ensure message handling runs on main thread, add reply-timeout logic for language requests, support fire-and-forget messages, and improve enqueue/flush logic. - WatchConnectivityManager & SettingsView: publish shared session state on force-logout/logout and improve account-switch/token handling; clear DataStore error and reset recovery state after token updates. - DataStore & WatchL10n: add recovery-in-progress guard to avoid duplicate recovery runs, reset language version tracking on account switch, make WatchL10n.setLanguage main-thread safe. - TokenManager & SharedKeychainManager: remove old keychain observer plumbing and instead publish shared session state when active token changes or is deleted. - UI tweaks: reduce icon/text sizes and spacing in pairing view; only show sync button when paired; Settings logout now also publishes shared state. - Watch sync wiring in Flutter: replace direct watch_connectivity usage with a MethodChannel-backed WatchSyncHelper.sendMessageToWatch and onWatchMessage callback; main and pairing UI updated accordingly. - Kreta client: replace simple boolean mutex with a Completer-based mutex and timeout handling to avoid busy-waiting. - LiveActivityService: throttle/avoid frequent activity recreation (cache last recreation time), skip placeholder creation when called from background, and minor cache-clearing adjustments. - HomeScreen: add WidgetsBindingObserver to manage lifecycle, prevent prefetch while backgrounded, debounce prefetch, ensure LiveActivity registration runs once and refresh on resume after first prefetch. These changes increase robustness of token sync and account switching, reduce race conditions and duplicate work, and avoid conflicts between WCSession and Flutter plugin delegates.
552 lines
18 KiB
Swift
552 lines
18 KiB
Swift
import Foundation
|
|
import Observation
|
|
import WidgetKit
|
|
|
|
// MARK: - Cache Wrapper
|
|
|
|
struct CachedWatchData: Codable {
|
|
let widgetData: WidgetData
|
|
let lastUpdated: Date
|
|
}
|
|
|
|
// MARK: - DataStore
|
|
|
|
@Observable
|
|
class DataStore {
|
|
static let shared = DataStore()
|
|
|
|
var data: WidgetData?
|
|
var lastUpdated: Date?
|
|
var isLoading: Bool = false
|
|
var error: String?
|
|
|
|
var isRecoveringToken: Bool = false
|
|
|
|
private(set) var recoveryAttempted: Bool = false
|
|
|
|
private(set) var hasToken: Bool = false
|
|
|
|
var needsReauth: Bool {
|
|
(error == "token_expired" || error == "no_token") && recoveryAttempted && !isRecoveringToken
|
|
}
|
|
|
|
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()
|
|
loadFromCache()
|
|
}
|
|
|
|
|
|
var hasValidToken: Bool {
|
|
TokenManager.shared.loadToken() != nil
|
|
}
|
|
|
|
func checkTokenState() {
|
|
hasToken = TokenManager.shared.loadToken() != nil
|
|
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
|
|
WatchL10n.shared.resetLanguageVersionTracking()
|
|
}
|
|
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
|
|
} else {
|
|
setLastHandledSessionActiveStudentIdNorm(nil)
|
|
}
|
|
|
|
setLastHandledSessionStateVersion(state.stateVersion)
|
|
checkTokenState()
|
|
}
|
|
|
|
// MARK: - Cache Loading
|
|
|
|
func loadFromCache() {
|
|
if let widgetData = WidgetData.load() {
|
|
self.data = widgetData
|
|
self.lastUpdated = widgetData.lastUpdated
|
|
return
|
|
}
|
|
|
|
guard let cachedData = loadWatchCache() else {
|
|
return
|
|
}
|
|
|
|
self.data = cachedData.widgetData
|
|
self.lastUpdated = cachedData.lastUpdated
|
|
}
|
|
|
|
private func loadWatchCache() -> CachedWatchData? {
|
|
guard let containerURL = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupID
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
|
|
|
guard let cacheData = try? Data(contentsOf: fileURL) else {
|
|
return nil
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
|
|
return try? decoder.decode(CachedWatchData.self, from: cacheData)
|
|
}
|
|
|
|
private func saveToCache(_ data: WidgetData) {
|
|
guard let containerURL = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupID
|
|
) else {
|
|
return
|
|
}
|
|
|
|
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
|
let cached = CachedWatchData(widgetData: data, lastUpdated: Date())
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
|
|
do {
|
|
let encodedData = try encoder.encode(cached)
|
|
try encodedData.write(to: fileURL)
|
|
} catch {
|
|
self.error = "Failed to save cache"
|
|
}
|
|
}
|
|
|
|
// MARK: - Cache Management
|
|
|
|
func clearCache() {
|
|
guard let containerURL = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupID
|
|
) else { return }
|
|
|
|
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
|
|
data = nil
|
|
lastUpdated = nil
|
|
|
|
print("[Watch] Cache cleared")
|
|
}
|
|
|
|
func clearAll() {
|
|
clearCache()
|
|
error = nil
|
|
isLoading = false
|
|
checkTokenState()
|
|
|
|
print("[Watch] All data cleared")
|
|
}
|
|
|
|
func clearError() {
|
|
error = nil
|
|
print("[Watch] Error cleared")
|
|
}
|
|
|
|
func setReauthRequired() {
|
|
error = "token_expired"
|
|
print("[Watch] Reauth required state set")
|
|
}
|
|
|
|
func resetRecoveryState() {
|
|
recoveryAttempted = false
|
|
error = nil
|
|
print("[Watch] Recovery state reset")
|
|
}
|
|
|
|
func attemptTokenRecovery() async -> Bool {
|
|
guard !isRecoveringToken else {
|
|
print("[Watch] Token recovery already in progress")
|
|
return false
|
|
}
|
|
|
|
isRecoveringToken = true
|
|
recoveryAttempted = false
|
|
error = nil
|
|
print("[Watch] Starting token recovery via central method...")
|
|
|
|
defer {
|
|
isRecoveringToken = false
|
|
}
|
|
|
|
if let token = TokenManager.shared.loadToken(), !TokenManager.shared.isTokenExpired() {
|
|
print("[Watch] Recovery: Token is already valid")
|
|
checkTokenState()
|
|
return true
|
|
}
|
|
|
|
if let _ = await TokenManager.shared.recoverToken() {
|
|
print("[Watch] Recovery: Central recovery succeeded")
|
|
checkTokenState()
|
|
return true
|
|
}
|
|
|
|
print("[Watch] Recovery: All attempts failed")
|
|
recoveryAttempted = true
|
|
self.error = "token_expired"
|
|
return false
|
|
}
|
|
|
|
private func refreshComplications() {
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
print("[Watch] Complications refreshed")
|
|
}
|
|
|
|
// MARK: - Proactive Token Refresh
|
|
|
|
func refreshTokenProactively() async {
|
|
guard hasValidToken else { return }
|
|
await TokenManager.shared.refreshTokenProactively()
|
|
checkTokenState()
|
|
}
|
|
|
|
// MARK: - Data Refresh
|
|
|
|
func refreshAll() async {
|
|
guard !isLoading else {
|
|
print("[Watch] DataStore.refreshAll() already in progress, skipping duplicate call")
|
|
return
|
|
}
|
|
|
|
print("[Watch] DataStore.refreshAll() called")
|
|
isLoading = true
|
|
error = nil
|
|
|
|
defer { isLoading = false }
|
|
|
|
await TokenManager.shared.refreshTokenProactively()
|
|
|
|
guard hasValidToken else {
|
|
print("[Watch] No valid token, setting error = no_token")
|
|
error = "no_token"
|
|
return
|
|
}
|
|
|
|
do {
|
|
let (startOfWeek, endOfWeek) = getCurrentWeekDateRange()
|
|
|
|
async let timetableTask = KretaAPIClient.shared.fetchTimetable(
|
|
from: startOfWeek,
|
|
to: endOfWeek
|
|
)
|
|
async let gradesTask = KretaAPIClient.shared.fetchGrades()
|
|
|
|
let (lessons, grades) = try await (timetableTask, gradesTask)
|
|
|
|
let timetableData = buildTimetableData(from: lessons)
|
|
let averagesData = buildAveragesData(from: grades)
|
|
|
|
let widgetData = WidgetData(
|
|
lastUpdated: Date(),
|
|
locale: Locale.current.language.languageCode?.identifier ?? "hu",
|
|
theme: "dark",
|
|
timetable: timetableData,
|
|
grades: grades,
|
|
averages: averagesData
|
|
)
|
|
|
|
self.data = widgetData
|
|
self.lastUpdated = Date()
|
|
|
|
saveToCache(widgetData)
|
|
|
|
refreshComplications()
|
|
|
|
print("[Watch] refreshAll() completed successfully")
|
|
|
|
} catch let error as APIError {
|
|
handleAPIError(error)
|
|
} catch {
|
|
print("[Watch] refreshAll() network error: \(error)")
|
|
self.error = "network"
|
|
}
|
|
}
|
|
|
|
private var isRecoveryInProgress: Bool = false
|
|
|
|
func refreshAllWithRecovery() async {
|
|
guard !isRecoveryInProgress && !isLoading else {
|
|
print("[Watch] refreshAllWithRecovery() already in progress or refreshAll() running, skipping duplicate call")
|
|
return
|
|
}
|
|
isRecoveryInProgress = true
|
|
defer { isRecoveryInProgress = false }
|
|
|
|
reconcileSharedSessionState()
|
|
WatchL10n.shared.refreshFromiPhoneAndSharedState()
|
|
|
|
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: 2_000_000_000)
|
|
checkTokenState()
|
|
}
|
|
|
|
await refreshAll()
|
|
|
|
guard error == "token_expired" || error == "no_token" else {
|
|
return
|
|
}
|
|
|
|
print("[Watch] Token issue after refreshAll(), starting auto-recovery flow...")
|
|
let recovered = await attemptTokenRecovery()
|
|
if recovered {
|
|
await refreshAll()
|
|
}
|
|
}
|
|
|
|
/// Handles API errors and maps them to user-friendly messages
|
|
private func handleAPIError(_ error: APIError) {
|
|
print("[Watch] handleAPIError: \(error)")
|
|
switch error {
|
|
case .tokenError(let tokenError):
|
|
switch tokenError {
|
|
case .noToken:
|
|
print("[Watch] Setting error = no_token")
|
|
self.error = "no_token"
|
|
case .refreshExpired, .invalidGrant:
|
|
print("[Watch] Setting error = token_expired")
|
|
self.error = "token_expired"
|
|
case .invalidResponse, .networkError:
|
|
print("[Watch] Setting error = network (token error)")
|
|
self.error = "network"
|
|
}
|
|
case .unauthorized:
|
|
print("[Watch] Setting error = token_expired (unauthorized)")
|
|
self.error = "token_expired"
|
|
case .requestFailed(let statusCode):
|
|
if statusCode >= 500 {
|
|
print("[Watch] Setting error = api_error (server error \(statusCode))")
|
|
self.error = "api_error"
|
|
} else {
|
|
print("[Watch] Setting error = network (request failed \(statusCode))")
|
|
self.error = "network"
|
|
}
|
|
case .decodingFailed, .invalidURL:
|
|
print("[Watch] Setting error = network")
|
|
self.error = "network"
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Processing
|
|
|
|
private func buildTimetableData(from lessons: [WidgetLesson]) -> TimetableData {
|
|
let today = Date()
|
|
let todayString = formatDateForComparison(today)
|
|
let tomorrowString = formatDateForComparison(today.addingTimeInterval(86400))
|
|
|
|
let todayLessons = lessons.filter { $0.date == todayString }.sorted { $0.start < $1.start }
|
|
let tomorrowLessons = lessons.filter { $0.date == tomorrowString }.sorted { $0.start < $1.start }
|
|
|
|
var nextSchoolDayLessons: [WidgetLesson]? = nil
|
|
var nextSchoolDayDateString: String? = nil
|
|
|
|
for daysOffset in 2...14 {
|
|
let checkDate = today.addingTimeInterval(TimeInterval(daysOffset * 86400))
|
|
let checkDateString = formatDateForComparison(checkDate)
|
|
let checkLessons = lessons.filter { $0.date == checkDateString }
|
|
|
|
if !checkLessons.isEmpty {
|
|
nextSchoolDayLessons = checkLessons.sorted { $0.start < $1.start }
|
|
nextSchoolDayDateString = checkDateString
|
|
break
|
|
}
|
|
}
|
|
|
|
let currentBreak: BreakInfo? = nil
|
|
|
|
return TimetableData(
|
|
today: todayLessons,
|
|
tomorrow: tomorrowLessons,
|
|
nextSchoolDay: nextSchoolDayLessons,
|
|
nextSchoolDayDate: nextSchoolDayDateString,
|
|
currentBreak: currentBreak,
|
|
allLessons: lessons
|
|
)
|
|
}
|
|
|
|
/// Builds AveragesData from grades (matching Flutter's calculation)
|
|
private func buildAveragesData(from grades: [WidgetGrade]) -> AveragesData {
|
|
guard !grades.isEmpty else {
|
|
return AveragesData(overall: nil, subjects: [])
|
|
}
|
|
|
|
var subjectGradesMap: [String: [(value: Int, weight: Double)]] = [:]
|
|
|
|
for grade in grades {
|
|
if let numeric = grade.normalizedNumericValue {
|
|
let key = grade.subject.uid
|
|
let weight = Double(grade.weightPercentage ?? 100) / 100.0
|
|
subjectGradesMap[key, default: []].append((value: numeric, weight: weight))
|
|
}
|
|
}
|
|
|
|
var subjectAverages: [SubjectAverage] = []
|
|
|
|
for (uid, gradeValues) in subjectGradesMap {
|
|
if let firstGrade = grades.first(where: { $0.subject.uid == uid }) {
|
|
var weightedSum = 0.0
|
|
var totalWeight = 0.0
|
|
|
|
for (value, weight) in gradeValues {
|
|
weightedSum += Double(value) * weight
|
|
totalWeight += weight
|
|
}
|
|
|
|
let average = totalWeight > 0 ? weightedSum / totalWeight : Double.nan
|
|
|
|
if !average.isNaN {
|
|
subjectAverages.append(
|
|
SubjectAverage(
|
|
uid: uid,
|
|
name: firstGrade.subject.name,
|
|
average: average,
|
|
gradeCount: gradeValues.count
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
let overall: Double?
|
|
if !subjectAverages.isEmpty {
|
|
let sumOfAverages = subjectAverages.reduce(0.0) { $0 + $1.average }
|
|
overall = sumOfAverages / Double(subjectAverages.count)
|
|
} else {
|
|
overall = nil
|
|
}
|
|
|
|
return AveragesData(overall: overall, subjects: subjectAverages)
|
|
}
|
|
|
|
private func getCurrentWeekDateRange() -> (start: Date, end: Date) {
|
|
let calendar = Calendar.current
|
|
let today = Date()
|
|
|
|
let weekday = calendar.component(.weekday, from: today)
|
|
let daysToMonday = weekday == 1 ? -6 : (2 - weekday)
|
|
let monday = calendar.date(byAdding: .day, value: daysToMonday, to: today)!
|
|
|
|
let nextSunday = calendar.date(byAdding: .day, value: 13, to: monday)!
|
|
|
|
return (monday, nextSunday)
|
|
}
|
|
|
|
private func formatDateForComparison(_ date: Date) -> String {
|
|
let calendar = Calendar.current
|
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
|
return String(format: "%04d-%02d-%02d",
|
|
components.year ?? 0,
|
|
components.month ?? 0,
|
|
components.day ?? 0)
|
|
}
|
|
|
|
// MARK: - Computed Helpers
|
|
|
|
var timeSinceUpdate: String? {
|
|
guard let lastUpdated = lastUpdated else { return nil }
|
|
|
|
let elapsed = Date().timeIntervalSince(lastUpdated)
|
|
|
|
if elapsed < 60 {
|
|
return "time_now".localized
|
|
}
|
|
|
|
// Minutes
|
|
let minutes = Int(elapsed / 60)
|
|
if minutes < 60 {
|
|
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
|
|
? "time_since_hours_one".localized
|
|
: "time_since_hours_many".localized(hours)
|
|
}
|
|
|
|
// Days
|
|
let days = Int(elapsed / 86400)
|
|
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)
|
|
var isStale: Bool {
|
|
guard let lastUpdated = lastUpdated else { return true }
|
|
|
|
let elapsed = Date().timeIntervalSince(lastUpdated)
|
|
return elapsed > 3600 // 1 hour
|
|
}
|
|
}
|