10 Commits

Author SHA1 Message Date
Horváth Gergely
dd3884de16 Remove onAppOpened and related LiveActivity state
Remove the _lastActivityRecreation field and the onAppOpened(...) method from LiveActivityService, which previously handled ending/creating activities, refreshing push tokens, fetching the week's timetable and scheduling background fetch. Update home_screen to call LiveActivityService.checkAndUpdateTimetable(...) instead of onAppOpened, and tweak related log messages to clarify the behavior. This simplifies LiveActivity lifecycle on app resume and stops forced activity recreation/token refresh performed by the removed method.
2026-02-27 22:50:32 +01:00
Horváth Gergely
c646ea2d51 Improve Watch sync, token, and live activity handling
Multiple fixes and improvements for watch <> phone sync, token recovery, and live activity behavior:

- WatchSessionManager: add mergeApplicationContext to avoid clobbering app context, add thread-safe pending auth queue and flush, add sendMessageToWatch API, ensure message handling runs on main thread, add reply-timeout logic for language requests, support fire-and-forget messages, and improve enqueue/flush logic.
- WatchConnectivityManager & SettingsView: publish shared session state on force-logout/logout and improve account-switch/token handling; clear DataStore error and reset recovery state after token updates.
- DataStore & WatchL10n: add recovery-in-progress guard to avoid duplicate recovery runs, reset language version tracking on account switch, make WatchL10n.setLanguage main-thread safe.
- TokenManager & SharedKeychainManager: remove old keychain observer plumbing and instead publish shared session state when active token changes or is deleted.
- UI tweaks: reduce icon/text sizes and spacing in pairing view; only show sync button when paired; Settings logout now also publishes shared state.
- Watch sync wiring in Flutter: replace direct watch_connectivity usage with a MethodChannel-backed WatchSyncHelper.sendMessageToWatch and onWatchMessage callback; main and pairing UI updated accordingly.
- Kreta client: replace simple boolean mutex with a Completer-based mutex and timeout handling to avoid busy-waiting.
- LiveActivityService: throttle/avoid frequent activity recreation (cache last recreation time), skip placeholder creation when called from background, and minor cache-clearing adjustments.
- HomeScreen: add WidgetsBindingObserver to manage lifecycle, prevent prefetch while backgrounded, debounce prefetch, ensure LiveActivity registration runs once and refresh on resume after first prefetch.

These changes increase robustness of token sync and account switching, reduce race conditions and duplicate work, and avoid conflicts between WCSession and Flutter plugin delegates.
2026-02-27 22:44:53 +01:00
Horváth Gergely
a22459794a Refactor timetable day selection and parsing
Add robust date parsing and simplify lesson selection logic. Introduces ISO8601 formatters (with and without fractional seconds) and updates parseNextSchoolDayDate to try both variants, plus existing yyyy-MM-dd fallback. Adds LessonCandidate, startOfDay and nextSchoolDay helpers and builds a sorted candidate list (today, tomorrow, next school day) to pick the appropriate lesson set and correctly set isNextDay/isNextSchoolDay flags. Cleans up previous branching-heavy logic and improves handling of edge cases (lessons finished, next-school-day resolution).
2026-02-24 14:38:06 +01:00
Horváth Gergely
91a526703e Improve watch UI and robust language sync
Several watch UI and sync improvements: clamp CountdownRing values and use clamped values for progress/color/display; add optional backgroundColor to FirkaCard and refactor HomeView to use lessonTitleWithStatus, lessonCardBackgroundColor and improved break time calculation and refresh handling (onChange of lastUpdated). TimetableView now shows status icons/colors via helper methods. DataStore now returns a localized "time_now" for recent updates. WatchSessionManager handles Flutter channel/unready states by serving shared language state when available and avoids empty language replies. Dart fixes: await initLang in settings, return null from WatchSyncHelper when uninitialized, and attempt to publish language to watch after initialization in main.
2026-02-20 11:03:47 +01:00
Horváth Gergely
38ff8af578 Use zip to iterate adjacent lessons
Replace index-based loops with zip(entry.lessons, entry.lessons.dropFirst()) in TimetableMediumView and TimetableLargeView to iterate adjacent lesson pairs. This improves readability and safety (avoids manual index arithmetic and potential out-of-bounds issues) while keeping the same break-detection logic.
2026-02-16 20:16:09 +01:00
Horváth Gergely
58c16e9aa8 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.
2026-02-16 19:05:17 +01:00
Horváth Gergely
748bff63ea Send language to Watch and handle locale changes
iOS: Update WatchSessionManager to send the current language via WCSession.default.updateApplicationContext with logging and error handling, in addition to existing transferUserInfo. Dart: await initLang during startup and add dispatcher.onLocaleChanged handler that re-initializes language when the app is in auto-language mode, logs locale changes, and triggers a global UI update.
2026-02-15 23:22:32 +01:00
Horváth Gergely
812c1a008e Migrate token storage to shared Keychain
Add SharedKeychainManager and migrate token storage from the old iCloud Key-Value (KV) approach to a synchronized Keychain-backed solution. Replace iCloudTokenManager usages across WatchSessionManager, TokenManager, and WatchConnectivityManager, update saveToken API to syncToSharedKeychain, and add KV-store migration logic that moves existing KV entries into the shared Keychain and clears the old KV store. Update entitlements (add keychain-access-groups) for both Runner and the Watch app, add/remove files in the Xcode project, delete iCloudTokenManager.swift. Also include access-group resolution, logging, and compatibility observer methods in the new manager.
2026-02-13 23:29:19 +01:00
Horváth Gergely
b71aa12751 Improve token recovery and iCloud probing
Track and surface classified token recovery failures and add a timed iCloud probe to accelerate recovery. TokenManager: introduce lastRecoveryFailure, clearLastRecoveryFailure(), and iCloudProbeTimeoutNs; add probeICloudTokenWithTimeout() and attempt an early iCloud-probe apply before refresh flow; record TokenError results from refresh attempts and abort recovery early on network errors; clear failure on successful actions; default lastRecoveryFailure to .noToken when all attempts fail. KretaAPIClient: clear failure on valid token/recovery success and throw a classified APIError when recovery produced a known TokenError. Overall: better diagnostics, faster iCloud-based short-circuiting, and more robust recovery error handling.
2026-02-12 13:50:14 +01:00
Horváth Gergely
c16cbdb186 Add robust Apple Watch token sync & force-logout
Improve Apple Watch sync resilience and token safety across iOS and Flutter. Key changes:

- Watch (watchOS): rate-limit phone token requests, handle "force_logout" via applicationContext/userInfo and perform local token deletion/cleanup. Added cooldown tracking.
- iPhone host (Swift): expose Flutter methods to check watch app installation, clear iCloud token, and send force-logout to the watch; refuse to forward expired tokens and avoid using iCloud fallback when Flutter reports reauth needed.
- Flutter (WatchSyncHelper/KretaClient): cache and check whether a paired Watch app is installed before touching iCloud/watch; reject expired access tokens (both incoming and outgoing) and prevent sending expired tokens to watch; added fresh-install cleanup to clear iCloud/local state once per install; added methods to notify watch of forced logout and to clear iCloud token.
- Initialization: run fresh-install cleanup before attempting iCloud recovery and skip recovery if cleanup ran.
- Login/settings/home UI: only attempt watch sync when a watch is installed; clear iCloud token on account removal (iOS); minor UX/timing and formatting cleanups.

These changes prevent propagation of expired tokens, reduce redundant phone/watch messaging, and provide a controlled force-logout flow for account removal or fresh installs.
2026-02-11 20:42:15 +01:00
31 changed files with 3091 additions and 910 deletions

View File

@@ -8,18 +8,23 @@ struct CountdownRing: View {
var lineWidth: CGFloat = 8
var displayOffset: Int = 0 // Add to displayed minutes (e.g., +1)
private var clampedRemainingMinutes: Int {
guard totalMinutes > 0 else { return 0 }
return max(0, min(remainingMinutes, totalMinutes))
}
var progress: Double {
guard totalMinutes > 0 else { return 0 }
return Double(totalMinutes - remainingMinutes) / Double(totalMinutes)
return Double(totalMinutes - clampedRemainingMinutes) / Double(totalMinutes)
}
var displayedMinutes: Int {
remainingMinutes + displayOffset
max(0, remainingMinutes + displayOffset)
}
var ringColor: Color {
if remainingMinutes < 5 { return .red }
if remainingMinutes < 10 { return .yellow }
if clampedRemainingMinutes < 5 { return .red }
if clampedRemainingMinutes < 10 { return .yellow }
return .green
}

View File

@@ -3,16 +3,25 @@ import SwiftUI
struct FirkaCard<Content: View>: View {
let content: Content
var isHighlighted: Bool = false
var backgroundColor: Color? = nil
init(isHighlighted: Bool = false, @ViewBuilder content: () -> Content) {
init(
isHighlighted: Bool = false,
backgroundColor: Color? = nil,
@ViewBuilder content: () -> Content
) {
self.isHighlighted = isHighlighted
self.backgroundColor = backgroundColor
self.content = content()
}
var body: some View {
content
.padding(12)
.background(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
.background(
backgroundColor ??
(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
)
.cornerRadius(12)
}
}

View File

@@ -44,6 +44,8 @@ struct ContentView: View {
}
}
.task {
dataStore.reconcileSharedSessionState()
WatchL10n.shared.reconcileFromSharedState()
dataStore.checkTokenState()
dataStore.loadFromCache()
if dataStore.hasToken {
@@ -54,6 +56,8 @@ struct ContentView: View {
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active && oldPhase != .active {
dataStore.reconcileSharedSessionState()
WatchL10n.shared.reconcileFromSharedState()
if shouldAutoRefresh {
print("[Watch] App came to foreground, data is stale (>10 min), refreshing...")
Task {
@@ -65,7 +69,22 @@ struct ContentView: View {
}
}
.onReceive(staleCheckTimer) { _ in
if scenePhase == .active && shouldAutoRefresh && !dataStore.isLoading {
guard scenePhase == .active else { return }
dataStore.reconcileSharedSessionState()
WatchL10n.shared.reconcileFromSharedState()
if !dataStore.hasToken {
dataStore.checkTokenState()
if dataStore.hasToken {
print("[Watch] Token appeared (iCloud Keychain sync?), refreshing...")
Task {
await dataStore.refreshAllWithRecovery()
}
}
return
}
if shouldAutoRefresh && !dataStore.isLoading {
print("[Watch] Data became stale (>10 min), auto-refreshing...")
Task {
await dataStore.refreshAllWithRecovery()
@@ -124,22 +143,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")
.font(.system(size: 50))
VStack(spacing: 10) {
Image(systemName: iconName)
.font(.system(size: 36))
.foregroundColor(.blue)
Text("pair_with_iphone".localized)
Text(titleKey.localized)
.font(.headline)
Text("open_firka_on_iphone".localized)
.font(.caption)
Text(descriptionKey.localized)
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if WCSession.default.isReachable {
if isWatchSystemPaired {
Button("sync_button".localized) {
onRequestToken?()
}

View File

@@ -6,6 +6,10 @@
<array>
<string>group.app.firka.firkaa</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.firka.shared</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
</dict>

View File

@@ -30,6 +30,7 @@ class WatchL10n {
private let languageKey = "watch_language"
private let syncWithiPhoneKey = "watch_sync_language_with_iphone"
private let lastAppliedSharedLanguageVersionKey = "watch_last_applied_shared_language_version"
private static let appGroupID = "group.app.firka.firkaa"
private var appGroupDefaults: UserDefaults? {
UserDefaults(suiteName: Self.appGroupID)
@@ -45,8 +46,9 @@ class WatchL10n {
var syncWithiPhone: Bool {
didSet {
UserDefaults.standard.set(syncWithiPhone, forKey: syncWithiPhoneKey)
appGroupDefaults?.set(syncWithiPhone, forKey: syncWithiPhoneKey)
if syncWithiPhone {
requestLanguageFromiPhone()
refreshFromiPhoneAndSharedState()
}
}
}
@@ -56,7 +58,13 @@ class WatchL10n {
private init() {
let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu"
self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian
self.syncWithiPhone = UserDefaults.standard.bool(forKey: syncWithiPhoneKey)
if let storedSyncPref = UserDefaults.standard.object(forKey: syncWithiPhoneKey) as? Bool {
self.syncWithiPhone = storedSyncPref
} else {
self.syncWithiPhone = true
UserDefaults.standard.set(true, forKey: syncWithiPhoneKey)
appGroupDefaults?.set(true, forKey: syncWithiPhoneKey)
}
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
loadStrings()
}
@@ -66,16 +74,78 @@ class WatchL10n {
}
func setLanguage(_ language: WatchLanguage) {
currentLanguage = language
loadStrings()
WidgetCenter.shared.reloadAllTimelines()
if Thread.isMainThread {
currentLanguage = language
loadStrings()
WidgetCenter.shared.reloadAllTimelines()
} else {
DispatchQueue.main.async { [self] in
currentLanguage = language
loadStrings()
WidgetCenter.shared.reloadAllTimelines()
}
}
}
func updateFromiPhone(languageCode: String) {
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 resetLanguageVersionTracking() {
setLastAppliedSharedLanguageVersion(0)
print("[WatchL10n] Language version tracking reset for account switch")
}
func reconcileFromSharedState() {
guard syncWithiPhone else { return }
guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return }
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 +183,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 +276,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 +369,20 @@ class WatchL10n {
"no_more_lessons": "Keine Stunden mehr heute",
"pair_with_iphone": "Mit iPhone koppeln",
"open_firka_on_iphone": "Öffne Firka auf deinem iPhone",
"login_on_iphone": "Auf iPhone anmelden",
"open_and_login_on_iphone": "Öffne Firka auf deinem iPhone und melde dich mit einem Konto an",
"updated": "Aktualisiert: %@",
"minutes": "Min",
"time_now": "jetzt",
"time_hours_minutes": "%d Std %d Min",
"time_hours": "%d Stunden",
"time_minutes_only": "%d Min",
"time_since_minutes_one": "vor 1 Min",
"time_since_minutes_many": "vor %d Min",
"time_since_hours_one": "vor 1 Std",
"time_since_hours_many": "vor %d Std",
"time_since_days_one": "vor 1 Tag",
"time_since_days_many": "vor %d Tagen",
// Timetable View
"free_day": "Freier Tag",

View File

@@ -32,6 +32,8 @@ class DataStore {
private let appGroupID = "group.app.firka.firkaa"
private let cacheFileName = "watch_data.json"
private let lastHandledSessionStateVersionKey = "firka.watch.last_handled_session_state_version"
private let lastHandledSessionActiveStudentIdNormKey = "firka.watch.last_handled_session_active_student_id_norm"
private init() {
checkTokenState()
@@ -48,6 +50,73 @@ 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
WatchL10n.shared.resetLanguageVersionTracking()
}
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
} else {
setLastHandledSessionActiveStudentIdNorm(nil)
}
setLastHandledSessionStateVersion(state.stateVersion)
checkTokenState()
}
// MARK: - Cache Loading
func loadFromCache() {
@@ -254,7 +323,31 @@ class DataStore {
}
}
private var isRecoveryInProgress: Bool = false
func refreshAllWithRecovery() async {
guard !isRecoveryInProgress && !isLoading else {
print("[Watch] refreshAllWithRecovery() already in progress or refreshAll() running, skipping duplicate call")
return
}
isRecoveryInProgress = true
defer { isRecoveryInProgress = false }
reconcileSharedSessionState()
WatchL10n.shared.refreshFromiPhoneAndSharedState()
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 {
@@ -422,24 +515,30 @@ class DataStore {
let elapsed = Date().timeIntervalSince(lastUpdated)
if elapsed < 60 {
return nil
return "time_now".localized
}
// Minutes
let minutes = Int(elapsed / 60)
if minutes < 60 {
return minutes == 1 ? "1 perce" : "\(minutes) perce"
return minutes == 1
? "time_since_minutes_one".localized
: "time_since_minutes_many".localized(minutes)
}
// Hours
let hours = Int(elapsed / 3600)
if hours < 24 {
return hours == 1 ? "1 órája" : "\(hours) órája"
return hours == 1
? "time_since_hours_one".localized
: "time_since_hours_many".localized(hours)
}
// Days
let days = Int(elapsed / 86400)
return days == 1 ? "1 napja" : "\(days) napja"
return days == 1
? "time_since_days_one".localized
: "time_since_days_many".localized(days)
}
/// Returns true if data is stale (> 1 hour old or never updated)

View File

@@ -4,6 +4,8 @@ import WatchConnectivity
class WatchConnectivityManager: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityManager()
private let lastAppliedTokenUpdateKey = "watch_last_applied_token_update_ms"
private let minPhoneTokenRequestInterval: TimeInterval = 5
private var lastPhoneTokenRequestAt: Date?
private override init() {
super.init()
@@ -35,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() {
@@ -92,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
@@ -179,6 +208,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
return
}
let now = Date()
if let lastPhoneTokenRequestAt,
now.timeIntervalSince(lastPhoneTokenRequestAt) < minPhoneTokenRequestInterval {
print("[Watch] Skipping token request due to cooldown")
return
}
lastPhoneTokenRequestAt = now
print("[Watch] Requesting token from iPhone...")
WCSession.default.sendMessage(
@@ -201,14 +238,26 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
}
private func processApplicationContext(_ context: [String: Any]) {
if (context["force_logout"] as? Bool) == true {
print("[Watch] Received force_logout via applicationContext")
handleForceLogoutFromPhone()
return
}
if let authDict = context["auth"] as? [String: Any] {
print("[Watch] Received auth from iPhone")
processAuthData(authDict)
}
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
)
}
}
@@ -222,18 +271,38 @@ 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")
DataStore.shared.setReauthRequired()
case "force_logout":
print("[Watch] Received force_logout notification from iPhone")
handleForceLogoutFromPhone()
default:
break
}
}
}
private func handleForceLogoutFromPhone() {
TokenManager.shared.deleteToken()
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: false,
activeStudentIdNorm: nil
)
DataStore.shared.clearAll()
DataStore.shared.resetRecoveryState()
DataStore.shared.checkTokenState()
}
func sendTokenToiPhoneInBackground() {
guard WCSession.default.activationState == .activated else {
print("[Watch] Cannot send token: session not activated")
@@ -296,8 +365,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
)
}
}
},
@@ -329,19 +404,24 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
let token = try decoder.decode(WatchToken.self, from: jsonData)
let currentToken = TokenManager.shared.loadToken()
let isAccountSwitch = currentToken != nil && !token.isSameAccount(as: currentToken!)
let shouldForceAccountSwitch: Bool
if incomingSentAtMs > 0,
let currentToken,
!token.isSameAccount(as: currentToken) {
shouldForceAccountSwitch = true
if isAccountSwitch {
if incomingSentAtMs > 0 {
shouldForceAccountSwitch = true
} else {
shouldForceAccountSwitch = token.isNewer(than: currentToken!)
}
} else {
shouldForceAccountSwitch = false
}
if incomingSentAtMs <= 0,
let currentToken,
!isAccountSwitch,
!token.isNewer(than: currentToken) {
print("[Watch] Ignoring stale token_update without sentAtMs")
print("[Watch] Ignoring stale token_update without sentAtMs (same account, not newer)")
return
}
@@ -349,14 +429,20 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
try TokenManager.shared.saveToken(
token,
syncToICloud: false,
syncToSharedKeychain: false,
forceAccountSwitch: shouldForceAccountSwitch
)
print("[Watch] Token saved successfully")
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: true,
activeStudentIdNorm: token.studentIdNorm
)
if incomingSentAtMs > 0 {
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
}
DataStore.shared.clearError()
DataStore.shared.resetRecoveryState()
DataStore.shared.checkTokenState()
Task {

View File

@@ -1,4 +1,5 @@
import SwiftUI
import WatchConnectivity
internal import Combine
struct HomeView: View {
@@ -92,10 +93,10 @@ struct HomeView: View {
.disabled(dataStore.isLoading || refreshStatus == .loading)
.padding(.top, 8)
.onChange(of: dataStore.isLoading) { oldValue, newValue in
if newValue && refreshStatus == .idle {
if newValue && refreshStatus != .loading {
wasLoadingFromBackground = true
}
if !newValue && wasLoadingFromBackground && refreshStatus == .idle {
if !newValue && wasLoadingFromBackground && refreshStatus != .loading {
wasLoadingFromBackground = false
if dataStore.error == nil && dataStore.data != nil {
refreshStatus = .success
@@ -110,6 +111,20 @@ struct HomeView: View {
}
}
}
.onChange(of: dataStore.lastUpdated) { oldValue, newValue in
guard let oldValue, let newValue else { return }
guard newValue > oldValue else { return }
guard dataStore.error == nil else { return }
guard refreshStatus != .loading else { return }
refreshStatus = .success
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
if refreshStatus == .success {
refreshStatus = .idle
}
}
}
}
private var refreshStatusText: String {
@@ -195,12 +210,20 @@ struct HomeView: View {
displayOffset: 1
)
.id("lesson-\(lesson.start.timeIntervalSince1970)")
FirkaCard(isHighlighted: true) {
FirkaCard(
isHighlighted: true,
backgroundColor: lessonCardBackgroundColor(
for: lesson,
isHighlighted: true
)
) {
VStack(alignment: .leading, spacing: 4) {
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
lessonTitleWithStatus(
lesson,
font: .subheadline,
weight: .semibold,
lineLimit: 2
)
HStack(spacing: 6) {
if let room = lesson.roomName {
@@ -221,11 +244,15 @@ struct HomeView: View {
.font(.caption)
.foregroundColor(.secondary)
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(next.displayName)
.font(.subheadline)
lessonTitleWithStatus(
next,
font: .subheadline,
weight: .regular,
lineLimit: 2
)
if let room = next.roomName {
Text(room)
.font(.caption2)
@@ -251,11 +278,16 @@ struct HomeView: View {
.font(.caption)
.foregroundColor(.secondary)
let remaining = max(0, Int(next.start.timeIntervalSince(now) / 60))
let remaining = max(0, Int(ceil(next.start.timeIntervalSince(now) / 60)))
let totalBreakMinutes: Int = {
guard let previous = previousLesson else { return max(remaining, 1) }
let breakSeconds = max(60, next.start.timeIntervalSince(previous.end))
return max(1, Int(ceil(breakSeconds / 60)))
}()
HStack(spacing: 10) {
CountdownRing(
totalMinutes: 15,
totalMinutes: totalBreakMinutes,
remainingMinutes: remaining,
label: "minutes".localized,
size: 56,
@@ -264,12 +296,14 @@ struct HomeView: View {
)
.id("break-\(next.start.timeIntervalSince1970)")
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
VStack(alignment: .leading, spacing: 4) {
Text("next_lesson".localized(next.displayName))
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
HStack(spacing: 4) {
Text("next_lesson".localized(next.displayName))
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
}
HStack(spacing: 6) {
if let room = next.roomName {
@@ -295,10 +329,14 @@ struct HomeView: View {
.font(.caption)
.foregroundColor(.secondary)
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: first)) {
VStack(alignment: .leading, spacing: 8) {
Text(first.displayName)
.font(.headline)
lessonTitleWithStatus(
first,
font: .headline,
weight: .regular,
lineLimit: 2
)
HStack {
if let room = first.roomName {
@@ -341,10 +379,14 @@ struct HomeView: View {
.foregroundColor(.secondary)
.padding(.top, 8)
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: nextLesson)) {
HStack {
Text(nextLesson.displayName)
.font(.subheadline)
lessonTitleWithStatus(
nextLesson,
font: .subheadline,
weight: .regular,
lineLimit: 2
)
Spacer()
Text(nextLesson.start, style: .time)
.font(.caption)
@@ -419,6 +461,46 @@ struct HomeView: View {
}
}
@ViewBuilder
private func lessonTitleWithStatus(
_ lesson: WidgetLesson,
font: Font,
weight: Font.Weight = .regular,
lineLimit: Int = 2
) -> some View {
Text(lesson.displayName)
.font(font)
.fontWeight(weight)
.lineLimit(lineLimit)
.foregroundColor(lessonPrimaryTextColor(for: lesson))
}
private func lessonPrimaryTextColor(for lesson: WidgetLesson) -> Color {
if lesson.isCancelled {
return .red
}
if lesson.isSubstitution {
return .yellow
}
return .primary
}
private func lessonCardBackgroundColor(
for lesson: WidgetLesson,
isHighlighted: Bool = false
) -> Color {
if lesson.isCancelled {
return Color.red.opacity(0.16)
}
if lesson.isSubstitution {
return Color.yellow.opacity(0.16)
}
if isHighlighted {
return Color.green.opacity(0.2)
}
return Color(white: 0.12)
}
// MARK: - Break/Vacation View
@ViewBuilder
@@ -438,17 +520,36 @@ struct HomeView: View {
// MARK: - No Token View
private var isWatchSystemPaired: Bool {
guard WCSession.isSupported() else { return false }
return WCSession.default.isCompanionAppInstalled
}
private var noTokenTitleKey: String {
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
}
private var noTokenDescriptionKey: String {
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
}
private var noTokenIconName: String {
isWatchSystemPaired
? "person.crop.circle.badge.exclamationmark"
: "iphone.and.arrow.right.inward"
}
private var noTokenView: some View {
VStack(spacing: 12) {
Image(systemName: "iphone.and.arrow.right.inward")
Image(systemName: noTokenIconName)
.font(.system(size: 44))
.foregroundColor(.blue)
Text("pair_with_iphone".localized)
Text(noTokenTitleKey.localized)
.font(.headline)
.multilineTextAlignment(.center)
Text("open_firka_on_iphone".localized)
Text(noTokenDescriptionKey.localized)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)

View File

@@ -289,7 +289,7 @@ struct ReauthRequiredView: View {
try TokenManager.shared.saveToken(
token,
syncToICloud: false,
syncToSharedKeychain: false,
forceAccountSwitch: shouldForceAccountSwitch
)

View File

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

View File

@@ -308,17 +308,17 @@ struct TimetableView: View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
.strikethrough(lesson.isCancelled)
.opacity(lesson.isCancelled ? 0.5 : 1)
HStack(spacing: 4) {
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if lesson.isSubstitution {
Image(systemName: "exclamationmark.circle.fill")
.font(.caption2)
.foregroundColor(.orange)
if let statusIcon = lessonStatusIconName(for: lesson) {
Image(systemName: statusIcon)
.font(.caption2)
.foregroundColor(lessonStatusColor(for: lesson))
}
}
Spacer()
@@ -340,11 +340,23 @@ struct TimetableView: View {
}
.font(.caption2)
.foregroundColor(.secondary)
.opacity(lesson.isCancelled ? 0.5 : 1)
}
}
}
.opacity(lesson.isCancelled ? 0.6 : 1)
}
private func lessonStatusIconName(for lesson: WidgetLesson) -> String? {
if lesson.isCancelled {
return "xmark.circle.fill"
}
if lesson.isSubstitution {
return "exclamationmark.circle.fill"
}
return nil
}
private func lessonStatusColor(for lesson: WidgetLesson) -> Color {
lesson.isCancelled ? .red : .yellow
}
}

View File

@@ -28,6 +28,11 @@ struct TimetableProvider: AppIntentTimelineProvider {
typealias Entry = TimetableEntry
typealias Intent = TimetableWidgetIntent
private struct LessonCandidate {
let lessons: [WidgetLesson]
let day: Date
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
@@ -36,9 +41,49 @@ struct TimetableProvider: AppIntentTimelineProvider {
return formatter
}()
private static let isoFormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
private func parseNextSchoolDayDate(_ dateString: String?) -> Date? {
guard let dateString = dateString else { return nil }
return Self.dateFormatter.date(from: dateString)
if let date = Self.isoFormatterWithFractional.date(from: dateString) {
return date
}
if let date = Self.isoFormatter.date(from: dateString) {
return date
}
if let date = Self.dateFormatter.date(from: dateString) {
return date
}
let trimmed = String(dateString.prefix(10))
return Self.dateFormatter.date(from: trimmed)
}
private func startOfDay(for lessons: [WidgetLesson], calendar: Calendar) -> Date? {
guard let first = lessons.first else { return nil }
return calendar.startOfDay(for: first.start)
}
private func nextSchoolDay(from data: WidgetData, calendar: Calendar) -> Date? {
if let firstNextLesson = data.timetable.nextSchoolDay?.first {
return calendar.startOfDay(for: firstNextLesson.start)
}
if let parsedDate = parseNextSchoolDayDate(data.timetable.nextSchoolDayDate) {
return calendar.startOfDay(for: parsedDate)
}
return nil
}
func placeholder(in context: Context) -> TimetableEntry {
@@ -176,9 +221,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
let midnight = calendar.startOfDay(for: now.addingTimeInterval(86400))
entries.append(createEntry(for: configuration, date: midnight))
if let nextSchoolDayDateString = data?.timetable.nextSchoolDayDate,
let nextSchoolDayDate = parseNextSchoolDayDate(nextSchoolDayDateString) {
let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate)
if let data = data,
let nextSchoolDay = nextSchoolDay(from: data, calendar: calendar) {
let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)!
if dayBeforeNextSchoolDay > now {
@@ -250,93 +294,47 @@ struct TimetableProvider: AppIntentTimelineProvider {
}
let entryDay = calendar.startOfDay(for: date)
let tomorrowOfEntryDay = calendar.date(byAdding: .day, value: 1, to: entryDay)!
var lessons = data.timetable.today
var isNextDay = false
let todayLessons = data.timetable.today
let tomorrowLessons = data.timetable.tomorrow
let nextSchoolDayLessons = data.timetable.nextSchoolDay ?? []
if let firstTodayLesson = lessons.first {
let todayLessonDay = calendar.startOfDay(for: firstTodayLesson.start)
var candidates: [LessonCandidate] = []
if entryDay > todayLessonDay {
lessons = data.timetable.tomorrow
if let firstTomorrowLesson = lessons.first {
let tomorrowLessonDay = calendar.startOfDay(for: firstTomorrowLesson.start)
isNextDay = entryDay < tomorrowLessonDay
}
} else {
let lastLesson = lessons.last
if let last = lastLesson, date > last.end {
lessons = data.timetable.tomorrow
isNextDay = true
}
}
} else {
lessons = data.timetable.tomorrow
if !lessons.isEmpty {
isNextDay = true
}
if let todayDay = startOfDay(for: todayLessons, calendar: calendar), !todayLessons.isEmpty {
candidates.append(LessonCandidate(lessons: todayLessons, day: todayDay))
}
if lessons.isEmpty {
if let nextSchoolDayLessons = data.timetable.nextSchoolDay, !nextSchoolDayLessons.isEmpty {
if let nextSchoolDayDate = parseNextSchoolDayDate(data.timetable.nextSchoolDayDate) {
let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate)
let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)!
if let tomorrowDay = startOfDay(for: tomorrowLessons, calendar: calendar), !tomorrowLessons.isEmpty {
candidates.append(LessonCandidate(lessons: tomorrowLessons, day: tomorrowDay))
}
if entryDay == nextSchoolDay {
let currentLesson = nextSchoolDayLessons.first { lesson in
return date >= lesson.start && date <= lesson.end
}
let nextLesson = nextSchoolDayLessons.first { $0.start > date }
if !nextSchoolDayLessons.isEmpty,
let resolvedNextSchoolDay = nextSchoolDay(from: data, calendar: calendar)
?? startOfDay(for: nextSchoolDayLessons, calendar: calendar) {
candidates.append(LessonCandidate(lessons: nextSchoolDayLessons, day: resolvedNextSchoolDay))
}
return TimetableEntry(
date: date,
configuration: configuration,
data: data,
lessons: nextSchoolDayLessons,
currentLesson: currentLesson,
nextLesson: nextLesson,
isNextDay: false,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: nil,
state: .normal,
debugInfo: WidgetData.lastError
)
}
candidates.sort { $0.day < $1.day }
if entryDay == dayBeforeNextSchoolDay {
return TimetableEntry(
date: date,
configuration: configuration,
data: data,
lessons: nextSchoolDayLessons,
currentLesson: nil,
nextLesson: nextSchoolDayLessons.first,
isNextDay: true,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: nil,
state: .normal,
debugInfo: WidgetData.lastError
)
}
}
// Pick the closest candidate that still has lessons ahead relative to this entry date.
let selectedCandidate = candidates.first { candidate in
if candidate.day > entryDay {
return true
}
return TimetableEntry(
date: date,
configuration: configuration,
data: data,
lessons: nextSchoolDayLessons,
currentLesson: nil,
nextLesson: nextSchoolDayLessons.first,
isNextDay: false,
isNextSchoolDay: true,
nextSchoolDayDateString: data.timetable.nextSchoolDayDate,
breakInfo: nil,
state: .normal,
debugInfo: WidgetData.lastError
)
if candidate.day == entryDay, let lastLesson = candidate.lessons.last {
return date <= lastLesson.end
}
return false
}
guard let selectedCandidate = selectedCandidate else {
let hadLessonsTodayButFinished = candidates.contains { candidate in
guard candidate.day == entryDay, let lastLesson = candidate.lessons.last else { return false }
return date > lastLesson.end
}
return TimetableEntry(
@@ -346,15 +344,23 @@ struct TimetableProvider: AppIntentTimelineProvider {
lessons: [],
currentLesson: nil,
nextLesson: nil,
isNextDay: isNextDay,
isNextDay: false,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: nil,
state: isNextDay ? .noMoreLessons : .unavailable,
state: hadLessonsTodayButFinished ? .noMoreLessons : .unavailable,
debugInfo: WidgetData.lastError
)
}
let lessons = selectedCandidate.lessons
let isToday = selectedCandidate.day == entryDay
let isNextDay = selectedCandidate.day == tomorrowOfEntryDay
let isNextSchoolDay = !isToday && !isNextDay
let nextSchoolDayDateString = isNextSchoolDay
? (data.timetable.nextSchoolDayDate ?? Self.dateFormatter.string(from: selectedCandidate.day))
: nil
let currentLesson = lessons.first { lesson in
return date >= lesson.start && date <= lesson.end
}
@@ -368,8 +374,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
currentLesson: currentLesson,
nextLesson: nextLesson,
isNextDay: isNextDay,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
isNextSchoolDay: isNextSchoolDay,
nextSchoolDayDateString: nextSchoolDayDateString,
breakInfo: nil,
state: .normal,
debugInfo: WidgetData.lastError

View File

@@ -117,8 +117,8 @@ struct TimetableMediumView: View {
var hasActiveBreak: Bool {
let checkDate = entry.date
for i in 0..<entry.lessons.count - 1 {
if checkDate > entry.lessons[i].end && checkDate < entry.lessons[i + 1].start {
for (currentLesson, nextLesson) in zip(entry.lessons, entry.lessons.dropFirst()) {
if checkDate > currentLesson.end && checkDate < nextLesson.start {
return true
}
}
@@ -209,8 +209,8 @@ struct TimetableLargeView: View {
var hasActiveBreak: Bool {
let checkDate = entry.date
for i in 0..<entry.lessons.count - 1 {
if checkDate > entry.lessons[i].end && checkDate < entry.lessons[i + 1].start {
for (currentLesson, nextLesson) in zip(entry.lessons, entry.lessons.dropFirst()) {
if checkDate > currentLesson.end && checkDate < nextLesson.start {
return true
}
}

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -12,13 +12,13 @@
213F8C0F6B5418B02DE14204 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 035E9CCBCC6585D0F5639031 /* Pods_Runner.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4F27D4D22F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */; };
4F27D4D32F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */; };
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */; };
4F30C7672E8FBF9D008BB46C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */; };
4F30C7692E8FBF9D008BB46C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */; };
4F30C7782E8FBF9F008BB46C /* LiveActivityWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */; };
4F5824802F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */; };
4F5824812F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */; };
4F5824832F3548B800B92EA7 /* WatchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5824822F3548B800B92EA7 /* WatchToken.swift */; };
4F5824842F3548B800B92EA7 /* WatchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5824822F3548B800B92EA7 /* WatchToken.swift */; };
4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */; };
@@ -177,12 +177,12 @@
4836947EC3B04B475B3DA1F8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
485F3791F25A288C749509B2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychainManager.swift; sourceTree = "<group>"; };
4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityMethodChannelManager.swift; sourceTree = "<group>"; };
4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LiveActivityWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetMethodChannel.swift; sourceTree = "<group>"; };
4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iCloudTokenManager.swift; sourceTree = "<group>"; };
4F5824822F3548B800B92EA7 /* WatchToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchToken.swift; sourceTree = "<group>"; };
4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = "<group>"; };
4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FirkaWatchComplicationsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -222,35 +222,35 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */ = {
4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Controls/AppControls.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */ = {
4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ActivityAttributes.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
4F5966082F2F0EB100A3DB03 /* Exceptions for "FirkaWatchComplications" folder in "FirkaWatchComplicationsExtension" target */ = {
4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */;
};
4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "LiveActivityWidget" folder in "LiveActivityWidget" target */ = {
4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */;
};
4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */ = {
4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -260,55 +260,10 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */,
4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "LiveActivityWidget" folder in "LiveActivityWidget" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = LiveActivityWidget;
sourceTree = "<group>";
};
4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F5966082F2F0EB100A3DB03 /* Exceptions for "FirkaWatchComplications" folder in "FirkaWatchComplicationsExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = FirkaWatchComplications;
sourceTree = "<group>";
};
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */,
4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = HomeWidgetsExtension;
sourceTree = "<group>";
};
4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "FirkaWatch Watch App";
sourceTree = "<group>";
};
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LiveActivityWidget; sourceTree = "<group>"; };
4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FirkaWatchComplications; sourceTree = "<group>"; };
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = "<group>"; };
4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "FirkaWatch Watch App"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -387,10 +342,10 @@
4F7701CD2F2EC1AA00B79171 /* API */ = {
isa = PBXGroup;
children = (
4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */,
4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */,
4F7701EE2F2EC2F500B79171 /* TokenManager.swift */,
4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */,
4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */,
);
path = API;
sourceTree = "<group>";
@@ -756,10 +711,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -848,10 +807,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -916,12 +879,12 @@
files = (
4F7701E62F2EC1AA00B79171 /* Grade.swift in Sources */,
4F7701E72F2EC1AA00B79171 /* WidgetColors.swift in Sources */,
4F27D4D22F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */,
4F7701E82F2EC1AA00B79171 /* Average.swift in Sources */,
4F5824842F3548B800B92EA7 /* WatchToken.swift in Sources */,
4FCB030D2F330F3B00418E63 /* KretaAPIModels.swift in Sources */,
4F7701E92F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */,
4F7701EA2F2EC1AA00B79171 /* Lesson.swift in Sources */,
4F5824802F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */,
4F7701EB2F2EC1AA00B79171 /* Subject.swift in Sources */,
4F7701EC2F2EC1AA00B79171 /* WidgetData.swift in Sources */,
4F7701EF2F2EC2F500B79171 /* TokenManager.swift in Sources */,
@@ -934,12 +897,12 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
4F27D4D32F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */,
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */,
4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */,
4F5824832F3548B800B92EA7 /* WatchToken.swift in Sources */,
4F5824812F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1067,21 +1030,21 @@
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1108,7 +1071,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
@@ -1127,7 +1090,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
@@ -1144,7 +1107,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
@@ -1171,7 +1134,7 @@
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1222,7 +1185,7 @@
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1270,7 +1233,7 @@
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1317,7 +1280,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1370,7 +1333,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1420,7 +1383,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1471,7 +1434,7 @@
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1522,7 +1485,7 @@
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1569,7 +1532,7 @@
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1600,9 +1563,9 @@
};
name = Profile;
};
4FF81B9C2F2EB4C300E95BA0 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
4FF81B9C2F2EB4C300E95BA0 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1620,10 +1583,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1652,9 +1615,9 @@
};
name = Debug;
};
4FF81B9D2F2EB4C300E95BA0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
4FF81B9D2F2EB4C300E95BA0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1672,10 +1635,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1701,9 +1664,9 @@
};
name = Release;
};
4FF81B9E2F2EB4C300E95BA0 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
4FF81B9E2F2EB4C300E95BA0 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1721,10 +1684,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1865,21 +1828,21 @@
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1901,21 +1864,21 @@
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

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

View File

@@ -46,7 +46,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>1068</string>
<string>1101</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@@ -10,6 +10,10 @@
<array>
<string>group.app.firka.firkaa</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.firka.shared</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
</dict>

View File

@@ -9,11 +9,30 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
private var isFlutterWatchSyncReady = false
private var pendingAuthPayloads: [[String: Any]] = []
private var pendingICloudRecoveryNotification = false
private let pendingAuthQueue = DispatchQueue(label: "app.firka.pendingAuthQueue")
override private init() {
super.init()
}
private func mergeApplicationContext(_ newEntries: [String: Any]) {
let session = WCSession.default
guard session.activationState == .activated else { return }
var merged = session.applicationContext
for (key, value) in newEntries {
merged[key] = value
}
if !newEntries.keys.contains("force_logout") {
merged.removeValue(forKey: "force_logout")
}
do {
try session.updateApplicationContext(merged)
} catch {
print("[WatchSessionManager] Failed to merge applicationContext: \(error)")
}
}
func setup(with messenger: FlutterBinaryMessenger) {
flutterChannel = FlutterMethodChannel(
name: "app.firka/watch_sync",
@@ -36,8 +55,30 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
self?.handleCheckiCloudToken(result: result)
case "saveTokeToniCloud":
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)
case "sendMessageToWatch":
self?.handleSendMessageToWatch(arguments: call.arguments, result: result)
default:
result(FlutterMethodNotImplemented)
}
@@ -82,6 +123,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,
@@ -101,8 +149,16 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return tokenData
}
private func fallbackTokenFromiCloud() -> [String: Any]? {
guard let token = iCloudTokenManager.shared.loadToken() else {
private func isTokenUsable(_ token: WatchToken, skewSeconds: TimeInterval = 60) -> Bool {
token.expiryDate > Date().addingTimeInterval(skewSeconds)
}
private func fallbackTokenFromSharedKeychain() -> [String: Any]? {
guard let token = SharedKeychainManager.shared.loadToken() else {
return nil
}
guard isTokenUsable(token, skewSeconds: 0) else {
print("[WatchSessionManager] Shared Keychain fallback token is expired, skipping fallback")
return nil
}
return tokenPayload(from: token)
@@ -116,12 +172,22 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
(lhs["refreshToken"] as? String) == (rhs["refreshToken"] as? String)
}
private func enqueuePendingAuth(_ authData: [String: Any]) {
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) {
return
private func tokenPayloadIsUsable(_ tokenData: [String: Any], skewMs: Int64 = 0) -> Bool {
guard let expiryMs = parseInt64(tokenData["expiryDate"]) else {
return false
}
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
return expiryMs > (nowMs + skewMs)
}
private func enqueuePendingAuth(_ authData: [String: Any]) {
pendingAuthQueue.sync {
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) {
return
}
pendingAuthPayloads.append(authData)
print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready")
}
pendingAuthPayloads.append(authData)
print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready")
}
private func forwardTokenToFlutter(_ authData: [String: Any]) {
@@ -145,13 +211,19 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
guard isFlutterWatchSyncReady else {
return
}
if !pendingAuthPayloads.isEmpty {
print("[WatchSessionManager] Flushing \(pendingAuthPayloads.count) queued token event(s) to Flutter")
let payloadsToFlush: [[String: Any]] = pendingAuthQueue.sync {
let copy = pendingAuthPayloads
pendingAuthPayloads.removeAll()
return copy
}
for authData in pendingAuthPayloads {
if !payloadsToFlush.isEmpty {
print("[WatchSessionManager] Flushing \(payloadsToFlush.count) queued token event(s) to Flutter")
}
for authData in payloadsToFlush {
flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
}
pendingAuthPayloads.removeAll()
if pendingICloudRecoveryNotification {
pendingICloudRecoveryNotification = false
@@ -172,21 +244,43 @@ 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
}
do {
WCSession.default.transferUserInfo([
"id": "token_update",
"auth": authData
])
result(nil)
print("[WatchSessionManager] Token sent to Watch")
} catch {
result(FlutterError(code: "TRANSFER_ERROR", message: error.localizedDescription, details: nil))
let session = WCSession.default
mergeApplicationContext(["auth": authData])
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) {
@@ -200,13 +294,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
return
}
do {
try WCSession.default.updateApplicationContext(["widget_data": jsonString])
result(nil)
print("[WatchSessionManager] Widget data sent to Watch")
} catch {
result(FlutterError(code: "UPDATE_ERROR", message: error.localizedDescription, details: nil))
}
mergeApplicationContext(["widget_data": jsonString])
result(nil)
print("[WatchSessionManager] Widget data sent to Watch")
}
private func handleSendLanguageToWatch(arguments: Any?, result: @escaping FlutterResult) {
@@ -215,15 +305,29 @@ 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
}
WCSession.default.transferUserInfo([
"id": "language_update",
"language": languageCode
])
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode)
if WCSession.default.activationState == .activated {
mergeApplicationContext([
"language": languageCode,
"language_state_version": sharedState.stateVersion
])
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
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")
}
@@ -275,17 +379,17 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
private func handleCheckiCloudToken(result: @escaping FlutterResult) {
print("[WatchSessionManager] Checking iCloud for token...")
print("[WatchSessionManager] Checking shared Keychain for token...")
guard let token = iCloudTokenManager.shared.loadToken() else {
print("[WatchSessionManager] No token in iCloud")
guard let token = SharedKeychainManager.shared.loadToken() else {
print("[WatchSessionManager] No token in shared Keychain")
result(["error": "no_token"])
return
}
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
print("[WatchSessionManager] Found iCloud token, expiry: \(formatter.string(from: token.expiryDate))")
print("[WatchSessionManager] Found shared Keychain token, expiry: \(formatter.string(from: token.expiryDate))")
result(tokenPayload(from: token))
}
@@ -296,6 +400,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,
@@ -323,15 +433,220 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
updatedAtMs: updatedAtMs
)
iCloudTokenManager.shared.saveToken(token, deviceName: "iPhone")
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"
print("[WatchSessionManager] Token saved to iCloud, expiry: \(formatter.string(from: expiryDate))")
print("[WatchSessionManager] Token saved to shared Keychain, expiry: \(formatter.string(from: expiryDate))")
result(nil)
}
private func handleIsWatchAppInstalled(result: @escaping FlutterResult) {
guard WCSession.isSupported() else {
result(false)
return
}
let session = WCSession.default
let installed = session.isPaired && session.isWatchAppInstalled
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)
}
private func handleSendLogoutToWatch(result: @escaping FlutterResult) {
guard WCSession.default.activationState == .activated else {
result(nil)
return
}
guard WCSession.default.isWatchAppInstalled else {
result(nil)
return
}
mergeApplicationContext(["force_logout": true])
WCSession.default.transferUserInfo([
"id": "force_logout"
])
print("[WatchSessionManager] Force logout sent to Watch")
result(nil)
}
private func handleSendMessageToWatch(arguments: Any?, result: @escaping FlutterResult) {
guard let message = arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil))
return
}
guard WCSession.default.activationState == .activated else {
result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil))
return
}
guard WCSession.default.isReachable else {
result(FlutterError(code: "NOT_REACHABLE", message: "Watch is not reachable", details: nil))
return
}
WCSession.default.sendMessage(message, replyHandler: nil, errorHandler: { error in
print("[WatchSessionManager] Failed to send message to Watch: \(error.localizedDescription)")
})
result(nil)
}
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
@@ -369,6 +684,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
DispatchQueue.main.async { [self] in
self._handleMessageWithReply(message: message, replyHandler: replyHandler)
}
}
private func _handleMessageWithReply(message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
print("[WatchSessionManager] Received message from Watch: \(message)")
guard let action = message["action"] as? String else {
@@ -379,7 +700,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
switch action {
case "requestToken":
if !self.isFlutterWatchSyncReady {
if let tokenData = self.fallbackTokenFromiCloud() {
if let tokenData = self.fallbackTokenFromSharedKeychain() {
print("[WatchSessionManager] Flutter not ready, returning iCloud token to Watch")
replyHandler(["auth": tokenData])
} else {
@@ -388,11 +709,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
return
}
DispatchQueue.main.async {
self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in
self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in
if let tokenData = result as? [String: Any] {
if let error = tokenData["error"] as? String {
if let fallbackToken = self.fallbackTokenFromiCloud() {
if error == "needsReauth" {
print("[WatchSessionManager] Flutter reported needsReauth, not using iCloud fallback")
replyHandler(["error": error])
} else if let fallbackToken = self.fallbackTokenFromSharedKeychain() {
print("[WatchSessionManager] Flutter returned error (\(error)), falling back to iCloud token")
replyHandler(["auth": fallbackToken])
} else {
@@ -400,11 +723,16 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
replyHandler(["error": error])
}
} else {
guard self.tokenPayloadIsUsable(tokenData) else {
print("[WatchSessionManager] Flutter token is expired, refusing to send to Watch")
replyHandler(["error": "needsReauth"])
return
}
print("[WatchSessionManager] Sending token to Watch")
replyHandler(["auth": tokenData])
}
} else {
if let fallbackToken = self.fallbackTokenFromiCloud() {
if let fallbackToken = self.fallbackTokenFromSharedKeychain() {
print("[WatchSessionManager] No Flutter token available, falling back to iCloud token")
replyHandler(["auth": fallbackToken])
} else {
@@ -413,18 +741,86 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
}
}
}
case "requestLanguage":
DispatchQueue.main.async {
self.flutterChannel?.invokeMethod("getLanguageForWatch", arguments: nil) { result in
if let languageCode = result as? String {
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
replyHandler(["language": languageCode])
} else {
print("[WatchSessionManager] No language from Flutter, defaulting to hu")
replyHandler(["language": "hu"])
if !self.isFlutterWatchSyncReady {
if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] Flutter not ready for language request, serving shared language: \(sharedState.languageCode)")
replyHandler([
"language": sharedState.languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] Flutter not ready for language request and no shared language is available")
replyHandler(["error": "language_not_ready"])
}
return
}
guard let flutterChannel = self.flutterChannel else {
if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] Flutter channel missing for language request, serving shared language: \(sharedState.languageCode)")
replyHandler([
"language": sharedState.languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] Flutter channel missing for language request and no shared language is available")
replyHandler(["error": "language_not_ready"])
}
return
}
var hasReplied = false
let timeoutWorkItem = DispatchWorkItem {
guard !hasReplied else { return }
hasReplied = true
if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] Flutter timeout, serving shared language: \(sharedState.languageCode)")
replyHandler([
"language": sharedState.languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] Flutter timeout and no shared language available")
replyHandler(["error": "timeout"])
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWorkItem)
flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in
timeoutWorkItem.cancel()
guard !hasReplied else { return }
hasReplied = true
if let languageCode = result as? String, !languageCode.isEmpty {
if let existingState = SharedLanguageStateManager.shared.loadState(),
existingState.languageCode == languageCode {
print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)")
replyHandler([
"language": languageCode,
"language_state_version": existingState.stateVersion
])
return
}
let sharedState = SharedLanguageStateManager.shared.publishState(
languageCode: languageCode
)
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
replyHandler([
"language": languageCode,
"language_state_version": sharedState.stateVersion
])
} else if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] No language from Flutter, serving last shared language: \(sharedState.languageCode)")
replyHandler([
"language": sharedState.languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] No language available from Flutter or shared state")
replyHandler(["error": "language_not_ready"])
}
}
@@ -436,27 +832,23 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
if !self.isFlutterWatchSyncReady {
print("[WatchSessionManager] Flutter not ready, queueing token from Watch")
DispatchQueue.main.async {
self.enqueuePendingAuth(tokenData)
}
self.enqueuePendingAuth(tokenData)
replyHandler(["success": true])
return
}
print("[WatchSessionManager] Receiving token from Watch")
DispatchQueue.main.async {
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
if let success = result as? Bool, success {
print("[WatchSessionManager] Flutter accepted Watch token")
replyHandler(["success": true])
} else if let resultDict = result as? [String: Any],
let success = resultDict["success"] as? Bool, success {
print("[WatchSessionManager] Flutter accepted Watch token")
replyHandler(["success": true])
} else {
print("[WatchSessionManager] Flutter rejected Watch token")
replyHandler(["error": "rejected"])
}
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
if let success = result as? Bool, success {
print("[WatchSessionManager] Flutter accepted Watch token")
replyHandler(["success": true])
} else if let resultDict = result as? [String: Any],
let success = resultDict["success"] as? Bool, success {
print("[WatchSessionManager] Flutter accepted Watch token")
replyHandler(["success": true])
} else {
print("[WatchSessionManager] Flutter rejected Watch token")
replyHandler(["error": "rejected"])
}
}
@@ -465,6 +857,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
print("[WatchSessionManager] Received fire-and-forget message from Watch: \(message)")
DispatchQueue.main.async {
self.flutterChannel?.invokeMethod("onWatchMessage", arguments: message)
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
print("[WatchSessionManager] Session did become inactive")
}

View File

@@ -90,6 +90,7 @@ class KretaAPIClient {
if let token = TokenManager.shared.loadToken() {
let expiryThreshold = token.expiryDate.addingTimeInterval(-60)
if Date() < expiryThreshold {
TokenManager.shared.clearLastRecoveryFailure()
return token
}
}
@@ -97,9 +98,15 @@ class KretaAPIClient {
print("[KretaAPI] Token invalid or expired, starting recovery...")
if let recoveredToken = await TokenManager.shared.recoverToken() {
print("[KretaAPI] Token recovery succeeded")
TokenManager.shared.clearLastRecoveryFailure()
return recoveredToken
}
if let recoveryFailure = TokenManager.shared.lastRecoveryFailure {
print("[KretaAPI] Token recovery failed with classified error: \(recoveryFailure)")
throw APIError.tokenError(recoveryFailure)
}
print("[KretaAPI] Token recovery failed")
throw APIError.tokenError(.noToken)
}

View File

@@ -0,0 +1,791 @@
import Foundation
import Security
/// Manages the synced Keychain storage for cross-device token sharing via iCloud Keychain.
class SharedKeychainManager {
static let shared = SharedKeychainManager()
private let accessGroupSuffix = "app.firka.shared"
private lazy var accessGroup: String = resolveAccessGroup()
private let service = "app.firka.shared.token"
private let account = "syncedToken"
#if os(iOS)
private let deviceName = "iPhone"
#elseif os(watchOS)
private let deviceName = "Watch"
#endif
private init() {}
var resolvedAccessGroup: String {
accessGroup
}
private func resolveAccessGroup() -> String {
let probeService = "\(service).probe"
let probeAccount = "probe"
let probeValue = Data("probe".utf8)
let cleanupQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: probeService,
kSecAttrAccount as String: probeAccount,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(cleanupQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: probeService,
kSecAttrAccount as String: probeAccount,
kSecValueData as String: probeValue,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecSuccess || addStatus == errSecDuplicateItem {
let readQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: probeService,
kSecAttrAccount as String: probeAccount,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let readStatus = SecItemCopyMatching(readQuery as CFDictionary, &result)
SecItemDelete(cleanupQuery as CFDictionary)
if readStatus == errSecSuccess,
let attributes = result as? [String: Any],
let resolvedGroup = attributes[kSecAttrAccessGroup as String] as? String,
!resolvedGroup.isEmpty {
print("[SharedKeychain] Resolved access group: \(resolvedGroup)")
return resolvedGroup
}
}
print("[SharedKeychain] Failed to resolve access group dynamically, using suffix fallback")
return accessGroupSuffix
}
// MARK: - Save Token (Synced)
@discardableResult
func saveToken(_ token: WatchToken, forceAccountSwitch: Bool = false) -> Bool {
if let existingToken = loadToken() {
if existingToken.isSameAccount(as: token) {
if !token.isNewer(than: existingToken) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[SharedKeychain] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
return false
}
} else {
if !forceAccountSwitch {
let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0
let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0
if incomingUpdatedAt > 0 &&
existingUpdatedAt > 0 &&
incomingUpdatedAt <= existingUpdatedAt {
print("[SharedKeychain] Ignoring cross-account stale token save from \(deviceName)")
return false
}
}
}
}
print("[SharedKeychain] Saving token to synced Keychain from \(deviceName)")
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(token)
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!
]
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!,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecSuccess {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[SharedKeychain] Token saved successfully to synced Keychain, expiry: \(formatter.string(from: token.expiryDate))")
return true
} else {
print("[SharedKeychain] Failed to save token to synced Keychain: \(status)")
return false
}
} catch {
print("[SharedKeychain] Failed to encode token: \(error)")
return false
}
}
// MARK: - Load Token (Synced)
func loadToken() -> WatchToken? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
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 {
if status != errSecItemNotFound {
print("[SharedKeychain] Failed to load token from synced Keychain: \(status)")
}
return nil
}
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let token = try decoder.decode(WatchToken.self, from: data)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[SharedKeychain] Token loaded from synced Keychain, expiry: \(formatter.string(from: token.expiryDate))")
return token
} catch {
print("[SharedKeychain] Failed to decode token from synced Keychain: \(error)")
return nil
}
}
// MARK: - Delete Token (Synced)
func deleteToken() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
print("[SharedKeychain] Token deleted from synced Keychain")
} else {
print("[SharedKeychain] Failed to delete token from synced Keychain: \(status)")
}
}
// MARK: - Migration from KV Store
func migrateFromKVStoreAndClear() -> WatchToken? {
let iCloudStore = NSUbiquitousKeyValueStore.default
iCloudStore.synchronize()
guard let accessToken = iCloudStore.string(forKey: "firka_access_token"),
let refreshToken = iCloudStore.string(forKey: "firka_refresh_token"),
let idToken = iCloudStore.string(forKey: "firka_id_token"),
let iss = iCloudStore.string(forKey: "firka_iss"),
let studentId = iCloudStore.string(forKey: "firka_student_id") else {
print("[SharedKeychain] No token found in KV Store to migrate")
clearKVStore()
return nil
}
let studentIdNorm = iCloudStore.longLong(forKey: "firka_student_id_norm")
let expiryTimestamp = iCloudStore.double(forKey: "firka_expiry_date")
let tokenVersionRaw = iCloudStore.longLong(forKey: "firka_token_version")
let updatedAtMsRaw = iCloudStore.longLong(forKey: "firka_updated_at_ms")
guard expiryTimestamp > 0 else {
print("[SharedKeychain] Invalid expiry date in KV Store")
clearKVStore()
return nil
}
let expiryDate = Date(timeIntervalSince1970: expiryTimestamp)
let token = WatchToken(
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
iss: iss,
studentId: studentId,
studentIdNorm: studentIdNorm,
expiryDate: expiryDate,
tokenVersion: tokenVersionRaw > 0 ? tokenVersionRaw : nil,
updatedAtMs: updatedAtMsRaw > 0 ? updatedAtMsRaw : nil
)
print("[SharedKeychain] Migrated token from KV Store, expiry: \(expiryDate)")
clearKVStore()
return token
}
func clearKVStore() {
let iCloudStore = NSUbiquitousKeyValueStore.default
let keysToRemove = [
"firka_access_token",
"firka_refresh_token",
"firka_id_token",
"firka_iss",
"firka_student_id",
"firka_student_id_norm",
"firka_expiry_date",
"firka_token_version",
"firka_updated_at_ms",
"firka_last_updated_device",
"firka_last_update_timestamp"
]
for key in keysToRemove {
iCloudStore.removeObject(forKey: key)
}
iCloudStore.synchronize()
print("[SharedKeychain] Cleared old KV Store data")
}
}
enum RefreshLeaseOwner: String {
case iphone
case watch
var peer: RefreshLeaseOwner {
switch self {
case .iphone:
return .watch
case .watch:
return .iphone
}
}
}
struct SharedSessionStateRecord: Codable {
let stateVersion: Int64
let hasAnyAccount: Bool
let activeStudentIdNorm: Int64?
let updatedAtMs: Int64
let sourceDevice: String
}
struct SharedLanguageStateRecord: Codable {
let stateVersion: Int64
let languageCode: String
let updatedAtMs: Int64
let expiresAtMs: Int64
let sourceDevice: String
}
class SharedLanguageStateManager {
static let shared = SharedLanguageStateManager()
private let service = "app.firka.shared.language_state"
private let account = "language_state"
private let accessGroup: String
private let maxTtlMs: Int64 = 7 * 24 * 60 * 60 * 1000
#if os(iOS)
private let sourceDevice = "iphone"
#elseif os(watchOS)
private let sourceDevice = "watch"
#endif
private init() {
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
}
private func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func encode(_ state: SharedLanguageStateRecord) -> Data? {
try? JSONEncoder().encode(state)
}
private func decode(_ data: Data) -> SharedLanguageStateRecord? {
try? JSONDecoder().decode(SharedLanguageStateRecord.self, from: data)
}
private func keychainQueryBase() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup
]
}
private func loadStateFromKeychain() -> SharedLanguageStateRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return decode(data)
}
private func storeStateInKeychain(_ state: SharedLanguageStateRecord) {
guard let data = encode(state) else {
print("[SharedLanguageState] Failed to encode state for keychain")
return
}
var deleteQuery = keychainQueryBase()
deleteQuery[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
SecItemDelete(deleteQuery as CFDictionary)
var addQuery = keychainQueryBase()
addQuery[kSecAttrSynchronizable as String] = kCFBooleanTrue!
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
addQuery[kSecValueData as String] = data
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
print("[SharedLanguageState] Failed to publish state to keychain: \(status)")
}
}
private func clearKeychainState() {
var query = keychainQueryBase()
query[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
SecItemDelete(query as CFDictionary)
}
private func isExpired(_ state: SharedLanguageStateRecord, now: Int64) -> Bool {
state.expiresAtMs <= now
}
func loadState() -> SharedLanguageStateRecord? {
let now = nowMs()
guard let keychainState = loadStateFromKeychain() else {
return nil
}
if isExpired(keychainState, now: now) {
clearKeychainState()
return nil
}
return keychainState
}
@discardableResult
func publishState(
languageCode: String,
ttlMs: Int64 = 24 * 60 * 60 * 1000
) -> SharedLanguageStateRecord {
let now = nowMs()
let previousVersion = loadStateFromKeychain()?.stateVersion ?? 0
let nextVersion = max(now, previousVersion + 1)
let effectiveTtl = max(min(ttlMs, maxTtlMs), 60_000)
let state = SharedLanguageStateRecord(
stateVersion: nextVersion,
languageCode: languageCode,
updatedAtMs: now,
expiresAtMs: now + effectiveTtl,
sourceDevice: sourceDevice
)
storeStateInKeychain(state)
return state
}
func clearState() {
clearKeychainState()
}
}
class SharedSessionStateManager {
static let shared = SharedSessionStateManager()
private let service = "app.firka.shared.session_state"
private let account = "session_state"
private let accessGroup: String
#if os(iOS)
private let sourceDevice = "iphone"
#elseif os(watchOS)
private let sourceDevice = "watch"
#endif
private init() {
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
}
private func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func encode(_ state: SharedSessionStateRecord) -> Data? {
try? JSONEncoder().encode(state)
}
private func decode(_ data: Data) -> SharedSessionStateRecord? {
try? JSONDecoder().decode(SharedSessionStateRecord.self, from: data)
}
func loadState() -> SharedSessionStateRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return decode(data)
}
@discardableResult
func publishState(
hasAnyAccount: Bool,
activeStudentIdNorm: Int64?
) -> SharedSessionStateRecord {
let now = nowMs()
let previousVersion = loadState()?.stateVersion ?? 0
let nextVersion = max(now, previousVersion + 1)
let state = SharedSessionStateRecord(
stateVersion: nextVersion,
hasAnyAccount: hasAnyAccount,
activeStudentIdNorm: hasAnyAccount ? activeStudentIdNorm : nil,
updatedAtMs: now,
sourceDevice: sourceDevice
)
if let data = encode(state) {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
print("[SharedSessionState] Failed to publish state: \(status)")
}
} else {
print("[SharedSessionState] Failed to encode state")
}
return state
}
func clearState() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(query as CFDictionary)
}
}
struct RefreshLeaseRecord: Codable {
let owner: String
let studentIdNorm: Int64
let operationId: String
let startedAtMs: Int64
let expiresAtMs: Int64
}
struct RefreshLeaseWaitResult {
let ready: Bool
let status: String
let waitedMs: Int64
let peerOperationId: String?
let leaseChanged: Bool
func asDictionary() -> [String: Any] {
var dict: [String: Any] = [
"ready": ready,
"status": status,
"waitedMs": waitedMs,
"leaseChanged": leaseChanged
]
if let peerOperationId {
dict["peerOperationId"] = peerOperationId
}
return dict
}
}
class RefreshLeaseManager {
static let shared = RefreshLeaseManager()
private let service = "app.firka.shared.refresh_lease"
private let accountPrefix = "lease"
private let accessGroup: String
private init() {
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
}
private func keyAccount(
owner: RefreshLeaseOwner,
studentIdNorm: Int64
) -> String {
"\(accountPrefix)_\(owner.rawValue)_\(studentIdNorm)"
}
private func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func encode(_ lease: RefreshLeaseRecord) -> Data? {
try? JSONEncoder().encode(lease)
}
private func decode(_ data: Data) -> RefreshLeaseRecord? {
try? JSONDecoder().decode(RefreshLeaseRecord.self, from: data)
}
func loadLease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64
) -> RefreshLeaseRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm),
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return decode(data)
}
@discardableResult
func acquireLease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64,
ttlMs: Int64,
operationId: String = UUID().uuidString
) -> RefreshLeaseRecord {
let now = nowMs()
let clampedTtl = max(ttlMs, 5_000)
let lease = RefreshLeaseRecord(
owner: owner.rawValue,
studentIdNorm: studentIdNorm,
operationId: operationId,
startedAtMs: now,
expiresAtMs: now + clampedTtl
)
if let data = encode(lease) {
let account = keyAccount(owner: owner, studentIdNorm: studentIdNorm)
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
print("[RefreshLease] Failed to acquire lease for \(owner.rawValue): \(status)")
}
} else {
print("[RefreshLease] Failed to encode lease for \(owner.rawValue)")
}
return lease
}
func releaseLease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64,
operationId: String? = nil
) {
if let operationId,
let current = loadLease(owner: owner, studentIdNorm: studentIdNorm),
current.operationId != operationId {
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm),
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(query as CFDictionary)
}
func clearLeases(studentIdNorm: Int64) {
releaseLease(owner: .iphone, studentIdNorm: studentIdNorm, operationId: nil)
releaseLease(owner: .watch, studentIdNorm: studentIdNorm, operationId: nil)
}
func clearAllLeases() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status != errSecSuccess {
return
}
guard let items = result as? [[String: Any]] else {
return
}
for item in items {
guard let account = item[kSecAttrAccount as String] as? String else {
continue
}
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
}
}
func waitForPeerLeaseRelease(
owner: RefreshLeaseOwner,
studentIdNorm: Int64,
maxWaitMs: Int64,
pollIntervalMs: Int64
) async -> RefreshLeaseWaitResult {
let startedAt = nowMs()
var deadline = startedAt + max(maxWaitMs, 1_000)
var lastFingerprint: String?
var leaseChanged = false
while nowMs() < deadline {
let now = nowMs()
guard let peer = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm) else {
return RefreshLeaseWaitResult(
ready: true,
status: leaseChanged ? "peer_lease_changed" : "peer_lease_missing",
waitedMs: now - startedAt,
peerOperationId: nil,
leaseChanged: leaseChanged
)
}
if peer.expiresAtMs <= now {
releaseLease(
owner: owner.peer,
studentIdNorm: studentIdNorm,
operationId: peer.operationId
)
return RefreshLeaseWaitResult(
ready: true,
status: "peer_lease_expired",
waitedMs: now - startedAt,
peerOperationId: peer.operationId,
leaseChanged: leaseChanged
)
}
let fingerprint = "\(peer.operationId)|\(peer.startedAtMs)|\(peer.expiresAtMs)"
if let previousFingerprint = lastFingerprint, previousFingerprint != fingerprint {
leaseChanged = true
deadline = min(deadline, peer.expiresAtMs + 5_000)
lastFingerprint = fingerprint
continue
}
lastFingerprint = fingerprint
deadline = min(deadline, peer.expiresAtMs + 5_000)
let sleepMs = max(min(pollIntervalMs, 1_000), 50)
try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
}
let waited = max(nowMs() - startedAt, 0)
let peerOperation = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm)?.operationId
return RefreshLeaseWaitResult(
ready: false,
status: "timed_out",
waitedMs: waited,
peerOperationId: peerOperation,
leaseChanged: leaseChanged
)
}
}

View File

@@ -43,6 +43,14 @@ class TokenManager {
private let activeStudentIdNormKey = "firka.active_student_id_norm"
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"
@@ -52,6 +60,7 @@ class TokenManager {
private let recoveryLock = NSLock()
private var recoveryInProgress = false
private var lastProactiveRefreshAttemptAt: Date?
private(set) var lastRecoveryFailure: TokenError?
#if os(watchOS)
private var lastPhoneRecoveryRequestAt: Date?
#endif
@@ -78,6 +87,45 @@ class TokenManager {
return recoveryInProgress
}
func clearLastRecoveryFailure() {
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
@@ -120,63 +168,52 @@ class TokenManager {
}
}
private init() {
iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in
guard let self = self else { return }
let preferredStudentIdNorm = self.getActiveStudentIdNorm()
let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60)
let preferredLocalToken = self.localTokenFromKeychainAndFile(
preferredStudentIdNorm: preferredStudentIdNorm
)
if let preferredStudentIdNorm,
iCloudToken.studentIdNorm != preferredStudentIdNorm,
preferredLocalToken != nil {
print("[TokenManager] Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
return
private func probeSharedKeychainTokenWithTimeout() async -> WatchToken? {
await withTaskGroup(of: WatchToken?.self) { group in
group.addTask {
SharedKeychainManager.shared.loadToken()
}
group.addTask { [iCloudProbeTimeoutNs] in
try? await Task.sleep(nanoseconds: iCloudProbeTimeoutNs)
return nil
}
let localToken = preferredLocalToken ?? self.localTokenFromKeychainAndFile()
if let localToken = localToken {
if iCloudToken.isNewer(than: localToken) {
print("[TokenManager] iCloud token is fresher, updating local cache")
try? self.saveTokenToKeychain(iCloudToken)
try? self.saveTokenToFile(iCloudToken)
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
#if os(watchOS)
DataStore.shared.checkTokenState()
#endif
#if os(iOS)
if isValidToken {
self.notifyiOSTokenRecovered()
}
#endif
} else {
print("[TokenManager] Local token is fresher or equal, ignoring iCloud update")
}
} else {
print("[TokenManager] No local token, using iCloud token")
try? self.saveTokenToKeychain(iCloudToken)
try? self.saveTokenToFile(iCloudToken)
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
#if os(watchOS)
DataStore.shared.checkTokenState()
#endif
#if os(iOS)
if isValidToken {
self.notifyiOSTokenRecovered()
}
#endif
}
let first = await group.next() ?? nil
group.cancelAll()
return first
}
}
private init() {
runKVStoreMigrationIfNeeded()
}
private let kvStoreMigrationKey = "firka_kv_store_migrated_v1"
private func runKVStoreMigrationIfNeeded() {
let alreadyMigrated = UserDefaults.standard.bool(forKey: kvStoreMigrationKey)
if alreadyMigrated {
return
}
print("[TokenManager] Running KV Store migration...")
if let migratedToken = SharedKeychainManager.shared.migrateFromKVStoreAndClear() {
SharedKeychainManager.shared.saveToken(migratedToken)
try? saveTokenToKeychain(migratedToken)
try? saveTokenToFile(migratedToken)
setActiveStudentIdNorm(migratedToken.studentIdNorm)
print("[TokenManager] KV Store migration completed, token migrated")
} else {
SharedKeychainManager.shared.clearKVStore()
print("[TokenManager] KV Store migration completed, no token to migrate")
}
UserDefaults.standard.set(true, forKey: kvStoreMigrationKey)
}
#if os(iOS)
private func notifyiOSTokenRecovered() {
print("[TokenManager] Valid token received from iCloud, notifying Flutter to clear reauth flag")
@@ -199,12 +236,12 @@ class TokenManager {
// MARK: - Load Token (active-account first)
func loadToken() -> WatchToken? {
let iCloudToken = iCloudTokenManager.shared.loadToken()
let sharedKeychainToken = SharedKeychainManager.shared.loadToken()
let keychainToken = loadTokenFromKeychain()
let fileToken = loadTokenFromFile()
var candidates: [(token: WatchToken, source: String)] = []
if let t = iCloudToken { candidates.append((t, "iCloud")) }
if let t = sharedKeychainToken { candidates.append((t, "sharedKeychain")) }
if let t = keychainToken { candidates.append((t, "keychain")) }
if let t = fileToken { candidates.append((t, "file")) }
@@ -213,7 +250,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 }
@@ -223,7 +277,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
}
@@ -233,8 +295,19 @@ class TokenManager {
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
}
}
let previousActiveStudentIdNorm = getActiveStudentIdNorm()
setActiveStudentIdNorm(freshest.token.studentIdNorm)
#if os(iOS)
if previousActiveStudentIdNorm != freshest.token.studentIdNorm {
_ = SharedSessionStateManager.shared.publishState(
hasAnyAccount: true,
activeStudentIdNorm: freshest.token.studentIdNorm
)
print("[TokenManager] Active account changed from \(previousActiveStudentIdNorm ?? 0) to \(freshest.token.studentIdNorm), published to SharedSessionState")
}
#endif
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
@@ -276,8 +349,16 @@ class TokenManager {
// MARK: - Delete Token
func deleteToken() {
print("[TokenManager] Deleting token from all storage locations")
SharedSessionStateManager.shared.publishState(hasAnyAccount: false, activeStudentIdNorm: nil)
if let previousToken = loadToken() {
RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm)
} else {
RefreshLeaseManager.shared.clearAllLeases()
}
deleteTokenFromKeychain()
iCloudTokenManager.shared.deleteToken()
SharedKeychainManager.shared.deleteToken()
UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey)
guard let filePath = getTokenFilePath() else { return }
@@ -287,10 +368,11 @@ class TokenManager {
// MARK: - Save Token
func saveToken(
_ token: WatchToken,
syncToICloud: Bool = false,
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) {
@@ -299,13 +381,19 @@ 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)
try saveTokenToKeychain(token)
if syncToICloud {
iCloudTokenManager.shared.saveToken(token, deviceName: deviceName)
if syncToSharedKeychain {
SharedKeychainManager.shared.saveToken(token, forceAccountSwitch: forceAccountSwitch)
}
guard let filePath = getTokenFilePath() else {
@@ -444,16 +532,25 @@ class TokenManager {
return
}
_ = try await refreshTokenInternal(token)
clearLastRecoveryFailure()
print("[TokenManager] Proactive token refresh succeeded")
} catch {
if let tokenError = error as? TokenError {
lastRecoveryFailure = tokenError
} else {
lastRecoveryFailure = .networkError
}
print("[TokenManager] Proactive token refresh failed: \(error)")
}
}
// MARK: - Central Token Recovery
func recoverToken() async -> WatchToken? {
clearLastRecoveryFailure()
if let validToken = loadToken(), !isTokenExpired() {
print("[TokenManager] Existing token is valid, skipping recovery flow")
clearLastRecoveryFailure()
return validToken
}
@@ -484,18 +581,49 @@ class TokenManager {
print("[TokenManager] Starting central token recovery...")
if let sharedToken = await probeSharedKeychainTokenWithTimeout() {
let now = Date()
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
sharedToken.studentIdNorm != preferredStudentIdNorm,
localTokenFromKeychainAndFile(preferredStudentIdNorm: preferredStudentIdNorm) != nil {
print("[TokenManager] Shared Keychain probe token belongs to inactive account, skipping direct apply")
} else if sharedToken.expiryDate > now.addingTimeInterval(60) {
print("[TokenManager] Shared Keychain probe found valid token, applying without recovery")
do {
try saveToken(sharedToken, syncToSharedKeychain: false)
clearLastRecoveryFailure()
return sharedToken
} catch {
print("[TokenManager] Failed to apply shared Keychain probe token: \(error)")
}
} else {
print("[TokenManager] Shared Keychain probe token exists but access is expired, continuing with refresh path")
}
} else {
print("[TokenManager] Shared Keychain probe timed out or no token available, continuing with refresh path")
}
print("[TokenManager] Step 1: Trying local token refresh...")
if let token = loadToken() {
if token.expiryDate > Date().addingTimeInterval(60) {
print("[TokenManager] Step 1 SUCCESS: Local token already valid")
clearLastRecoveryFailure()
return token
}
do {
let refreshedToken = try await refreshTokenInternal(token)
print("[TokenManager] Step 1 SUCCESS: Local refresh succeeded")
clearLastRecoveryFailure()
return refreshedToken
} catch {
print("[TokenManager] Step 1 FAILED: Local refresh failed: \(error)")
if let tokenError = error as? TokenError {
lastRecoveryFailure = tokenError
if tokenError == .networkError {
print("[TokenManager] Step 1 detected network error, aborting recovery flow")
return nil
}
}
}
} else {
print("[TokenManager] Step 1 SKIPPED: No local token found")
@@ -505,72 +633,93 @@ class TokenManager {
if let recoveredToken = await tryRecoverFromKeychainAndWatch() {
if recoveredToken.expiryDate > Date().addingTimeInterval(60) {
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token is already valid")
try? saveToken(recoveredToken, syncToICloud: false)
try? saveToken(recoveredToken, syncToSharedKeychain: false)
clearLastRecoveryFailure()
return recoveredToken
} else {
do {
let refreshedToken = try await refreshTokenInternal(recoveredToken)
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token refresh succeeded")
clearLastRecoveryFailure()
return refreshedToken
} catch {
print("[TokenManager] Step 2 FAILED: Keychain/Watch token refresh failed: \(error)")
if let tokenError = error as? TokenError {
lastRecoveryFailure = tokenError
if tokenError == .networkError {
print("[TokenManager] Step 2 detected network error, aborting recovery flow")
return nil
}
}
}
}
} else {
print("[TokenManager] Step 2 SKIPPED: No token from Keychain/Watch")
}
print("[TokenManager] Step 3: Trying iCloud recovery with retries...")
print("[TokenManager] Step 3: Trying shared Keychain recovery with retries...")
let retryDelays: [TimeInterval] = [0, 5, 10, 5, 10]
var iCloudHasToken = false
var sharedKeychainHasToken = false
for (attempt, delay) in retryDelays.enumerated() {
if delay > 0 {
if !iCloudHasToken && attempt > 0 {
print("[TokenManager] Step 3: Skipping retries - iCloud has no token")
if !sharedKeychainHasToken && attempt > 0 {
print("[TokenManager] Step 3: Skipping retries - shared Keychain has no token")
break
}
print("[TokenManager] Step 3: Waiting \(Int(delay))s before attempt \(attempt + 1)...")
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
print("[TokenManager] Step 3: iCloud attempt \(attempt + 1)/\(retryDelays.count)...")
print("[TokenManager] Step 3: Shared Keychain attempt \(attempt + 1)/\(retryDelays.count)...")
if let iCloudToken = iCloudTokenManager.shared.loadToken() {
if let sharedToken = SharedKeychainManager.shared.loadToken() {
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
iCloudToken.studentIdNorm != preferredStudentIdNorm {
sharedToken.studentIdNorm != preferredStudentIdNorm {
if localTokenFromKeychainAndFile(
preferredStudentIdNorm: preferredStudentIdNorm
) != nil {
print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
print("[TokenManager] Step 3: Ignoring shared Keychain token for inactive account (\(sharedToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
continue
}
print("[TokenManager] Step 3: Active account token missing locally, considering different-account iCloud token")
print("[TokenManager] Step 3: Active account token missing locally, considering different-account shared Keychain token")
}
iCloudHasToken = true
if iCloudToken.expiryDate > Date() {
print("[TokenManager] Step 3 SUCCESS: Found valid iCloud token, applying without immediate refresh")
try? saveToken(iCloudToken, syncToICloud: false)
return iCloudToken
sharedKeychainHasToken = true
if sharedToken.expiryDate > Date() {
print("[TokenManager] Step 3 SUCCESS: Found valid shared Keychain token, applying without immediate refresh")
try? saveToken(sharedToken, syncToSharedKeychain: false)
clearLastRecoveryFailure()
return sharedToken
} else {
print("[TokenManager] Step 3: iCloud token is expired, trying refresh anyway...")
print("[TokenManager] Step 3: Shared Keychain token is expired, trying refresh anyway...")
do {
let refreshedToken = try await refreshTokenInternal(iCloudToken)
print("[TokenManager] Step 3 SUCCESS: Expired iCloud token refresh succeeded on attempt \(attempt + 1)")
let refreshedToken = try await refreshTokenInternal(sharedToken)
print("[TokenManager] Step 3 SUCCESS: Expired shared Keychain token refresh succeeded on attempt \(attempt + 1)")
clearLastRecoveryFailure()
return refreshedToken
} catch {
print("[TokenManager] Step 3: Expired iCloud token refresh failed on attempt \(attempt + 1): \(error)")
print("[TokenManager] Step 3: Expired shared Keychain token refresh failed on attempt \(attempt + 1): \(error)")
if let tokenError = error as? TokenError {
lastRecoveryFailure = tokenError
if tokenError == .networkError {
print("[TokenManager] Step 3 detected network error, aborting retries")
return nil
}
}
}
}
} else {
print("[TokenManager] Step 3: No token in iCloud on attempt \(attempt + 1)")
print("[TokenManager] Step 3: No token in shared Keychain on attempt \(attempt + 1)")
if attempt == 0 {
iCloudHasToken = false
sharedKeychainHasToken = false
}
}
}
print("[TokenManager] All recovery attempts failed")
if lastRecoveryFailure == nil {
lastRecoveryFailure = .noToken
}
return nil
}
@@ -698,6 +847,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
@@ -717,13 +892,9 @@ class TokenManager {
updatedAtMs: nowMs
)
try saveToken(newToken, syncToICloud: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
#endif
try saveToken(newToken, syncToSharedKeychain: true)
return newToken
#endif
}
// MARK: - Refresh Token
@@ -732,6 +903,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
@@ -751,13 +948,9 @@ class TokenManager {
updatedAtMs: nowMs
)
try saveToken(newToken, syncToICloud: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
#endif
try saveToken(newToken, syncToSharedKeychain: true)
return newToken
#endif
}
// MARK: - Private Helper Methods
@@ -774,6 +967,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,
@@ -785,7 +979,11 @@ class TokenManager {
request.httpBody = encodeFormData(formParameters).data(using: .utf8)
do {
let (data, response) = try await URLSession.shared.data(for: request)
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = refreshRequestTimeout
configuration.timeoutIntervalForResource = refreshResourceTimeout
let session = URLSession(configuration: configuration)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TokenError.networkError

View File

@@ -1,210 +0,0 @@
import Foundation
class iCloudTokenManager {
static let shared = iCloudTokenManager()
private let iCloudStore = NSUbiquitousKeyValueStore.default
private let kAccessToken = "firka_access_token"
private let kRefreshToken = "firka_refresh_token"
private let kIdToken = "firka_id_token"
private let kIss = "firka_iss"
private let kStudentId = "firka_student_id"
private let kStudentIdNorm = "firka_student_id_norm"
private let kExpiryDate = "firka_expiry_date"
private let kTokenVersion = "firka_token_version"
private let kUpdatedAtMs = "firka_updated_at_ms"
private let kLastUpdatedDevice = "firka_last_updated_device"
private let kLastUpdateTimestamp = "firka_last_update_timestamp"
private var changeObserver: ((WatchToken) -> Void)?
private var isAvailable = false
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(iCloudStoreDidChange(_:)),
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: iCloudStore
)
isAvailable = iCloudStore.synchronize()
if isAvailable {
print("[iCloud] iCloud KeyValue Store available and synced")
} else {
print("[iCloud] iCloud not available (not signed in or disabled) - using local storage only")
}
}
func saveToken(_ token: WatchToken, deviceName: String) {
guard isAvailable else {
return
}
if let existingToken = loadToken() {
if existingToken.isSameAccount(as: token) {
if !token.isNewer(than: existingToken) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[iCloud] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
return
}
} else {
let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0
let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0
if incomingUpdatedAt > 0 &&
existingUpdatedAt > 0 &&
incomingUpdatedAt <= existingUpdatedAt {
print("[iCloud] Ignoring cross-account stale token save from \(deviceName)")
return
}
}
}
print("[iCloud] Saving token to iCloud from \(deviceName)")
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let updatedAtMs = token.effectiveUpdatedAtMs ?? nowMs
let tokenVersion = token.effectiveTokenVersion
iCloudStore.set(token.accessToken, forKey: kAccessToken)
iCloudStore.set(token.refreshToken, forKey: kRefreshToken)
iCloudStore.set(token.idToken, forKey: kIdToken)
iCloudStore.set(token.iss, forKey: kIss)
iCloudStore.set(token.studentId, forKey: kStudentId)
iCloudStore.set(token.studentIdNorm, forKey: kStudentIdNorm)
iCloudStore.set(token.expiryDate.timeIntervalSince1970, forKey: kExpiryDate)
if let tokenVersion {
iCloudStore.set(tokenVersion, forKey: kTokenVersion)
} else {
iCloudStore.removeObject(forKey: kTokenVersion)
}
iCloudStore.set(updatedAtMs, forKey: kUpdatedAtMs)
iCloudStore.set(deviceName, forKey: kLastUpdatedDevice)
iCloudStore.set(Double(updatedAtMs) / 1000.0, forKey: kLastUpdateTimestamp)
let success = iCloudStore.synchronize()
if success {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[iCloud] Token saved successfully, expiry: \(formatter.string(from: token.expiryDate))")
} else {
print("[iCloud] Failed to synchronize token to iCloud")
}
}
func loadToken() -> WatchToken? {
guard isAvailable else {
return nil
}
iCloudStore.synchronize()
guard let accessToken = iCloudStore.string(forKey: kAccessToken),
let refreshToken = iCloudStore.string(forKey: kRefreshToken),
let idToken = iCloudStore.string(forKey: kIdToken),
let iss = iCloudStore.string(forKey: kIss),
let studentId = iCloudStore.string(forKey: kStudentId) else {
print("[iCloud] No token found in iCloud")
return nil
}
let studentIdNorm = iCloudStore.longLong(forKey: kStudentIdNorm)
let expiryTimestamp = iCloudStore.double(forKey: kExpiryDate)
let tokenVersionRaw = iCloudStore.longLong(forKey: kTokenVersion)
let updatedAtMsRaw = iCloudStore.longLong(forKey: kUpdatedAtMs)
let fallbackUpdatedAt = Int64(iCloudStore.double(forKey: kLastUpdateTimestamp) * 1000.0)
let lastDevice = iCloudStore.string(forKey: kLastUpdatedDevice) ?? "unknown"
guard expiryTimestamp > 0 else {
print("[iCloud] Invalid expiry date in iCloud")
return nil
}
let expiryDate = Date(timeIntervalSince1970: expiryTimestamp)
let token = WatchToken(
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
iss: iss,
studentId: studentId,
studentIdNorm: studentIdNorm,
expiryDate: expiryDate,
tokenVersion: tokenVersionRaw > 0 ? tokenVersionRaw : nil,
updatedAtMs: updatedAtMsRaw > 0 ? updatedAtMsRaw : (fallbackUpdatedAt > 0 ? fallbackUpdatedAt : nil)
)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[iCloud] Token loaded from iCloud (last updated by: \(lastDevice)), expiry: \(formatter.string(from: expiryDate))")
return token
}
func deleteToken() {
guard isAvailable else {
return
}
print("[iCloud] Deleting token from iCloud")
iCloudStore.removeObject(forKey: kAccessToken)
iCloudStore.removeObject(forKey: kRefreshToken)
iCloudStore.removeObject(forKey: kIdToken)
iCloudStore.removeObject(forKey: kIss)
iCloudStore.removeObject(forKey: kStudentId)
iCloudStore.removeObject(forKey: kStudentIdNorm)
iCloudStore.removeObject(forKey: kExpiryDate)
iCloudStore.removeObject(forKey: kTokenVersion)
iCloudStore.removeObject(forKey: kUpdatedAtMs)
iCloudStore.removeObject(forKey: kLastUpdatedDevice)
iCloudStore.removeObject(forKey: kLastUpdateTimestamp)
iCloudStore.synchronize()
}
func observeChanges(_ observer: @escaping (WatchToken) -> Void) {
self.changeObserver = observer
}
@objc private func iCloudStoreDidChange(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let changeReason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else {
return
}
if changeReason == NSUbiquitousKeyValueStoreServerChange ||
changeReason == NSUbiquitousKeyValueStoreInitialSyncChange {
print("[iCloud] Token changed externally in iCloud")
if let token = loadToken() {
let lastDevice = iCloudStore.string(forKey: kLastUpdatedDevice) ?? "unknown"
print("[iCloud] Received updated token from: \(lastDevice)")
changeObserver?(token)
}
}
}
func getLastUpdatedDevice() -> String? {
guard isAvailable else {
return nil
}
iCloudStore.synchronize()
return iCloudStore.string(forKey: kLastUpdatedDevice)
}
func getLastUpdateTimestamp() -> Date? {
guard isAvailable else {
return nil
}
iCloudStore.synchronize()
let timestamp = iCloudStore.double(forKey: kLastUpdateTimestamp)
guard timestamp > 0 else { return nil }
return Date(timeIntervalSince1970: timestamp)
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
@@ -61,7 +62,7 @@ class ApiResponse<T> {
}
class KretaClient {
bool _tokenMutex = false;
Completer<void>? _tokenMutexCompleter;
TokenModel model;
Isar isar;
@@ -84,6 +85,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 ||
@@ -92,6 +131,13 @@ class KretaClient {
return;
}
final watchInstalled = await WatchSyncHelper.isWatchAppInstalled();
if (!watchInstalled) {
debugPrint(
'[KretaClient] Skipping Apple token sync because no paired Watch app is installed');
return;
}
try {
await WatchSyncHelper.saveTokenToiCloud(token);
} catch (e) {
@@ -148,8 +194,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);
@@ -194,6 +239,7 @@ class KretaClient {
isar: isar,
tokens: initData.tokens,
client: this,
allowExpiredAccessToken: true,
);
if (recovered) {
@@ -214,8 +260,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);
@@ -277,22 +322,29 @@ class KretaClient {
Future<T> _mutexCallback<T>(Future<T> Function() callback) async {
const maxWaitTime = Duration(seconds: 30);
final startTime = DateTime.now();
while (_tokenMutex) {
if (DateTime.now().difference(startTime) > maxWaitTime) {
logger.warning(
"[Mutex] Timeout waiting for token mutex, forcing release");
_tokenMutex = false;
break;
if (_tokenMutexCompleter != null) {
try {
await _tokenMutexCompleter!.future.timeout(maxWaitTime, onTimeout: () {
logger.warning(
"[Mutex] Timeout waiting for token mutex, forcing release");
if (_tokenMutexCompleter != null && !_tokenMutexCompleter!.isCompleted) {
_tokenMutexCompleter!.complete();
}
});
} catch (_) {
}
await Future.delayed(const Duration(milliseconds: 50));
}
_tokenMutex = true;
_tokenMutexCompleter = Completer<void>();
try {
return await callback();
} finally {
_tokenMutex = false;
final completer = _tokenMutexCompleter;
_tokenMutexCompleter = null;
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
}

View File

@@ -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');
@@ -755,6 +733,7 @@ class LiveActivityService {
return;
}
final liveActivityEnabled = await isEnabled(settingsStore, client);
final morningNotificationEnabled = _getCurrentMorningNotificationEnabled() ?? false;
_logger.info('onUserLogin: liveActivityEnabled=$liveActivityEnabled, morningNotificationEnabled=$morningNotificationEnabled');
@@ -990,54 +969,6 @@ class LiveActivityService {
}
}
/// Called when app is opened - sends timetable to backend, backend handles updates
/// IMPORTANT: Recreates Live Activity on every app open to refresh the 8-hour push token
static Future<void> onAppOpened({
required KretaClient client,
required String studentName,
SettingsStore? settingsStore,
}) async {
if (!Platform.isIOS || !_isInitialized) return;
try {
final enabled = await isEnabled(settingsStore, client);
if (!enabled) {
_logger.info('LiveActivity is disabled, ending any running activities');
await LiveActivityManager.endAllActivities();
return;
}
final activeActivities = await LiveActivityManager.getActiveActivities();
if (activeActivities.isNotEmpty) {
_logger.info('Ending existing activity to refresh push token (8-hour expiration)');
await LiveActivityManager.endAllActivities();
await Future.delayed(const Duration(milliseconds: 500));
}
final now = DateTime.now();
final todayStart = DateTime(now.year, now.month, now.day);
final startOfWeek = todayStart.subtract(Duration(days: now.weekday - 1));
final endOfWeek = startOfWeek.add(const Duration(days: 6));
final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek);
final allLessons = timetableResponse.response ?? [];
await _startPlaceholderActivity(allLessons, studentName);
_logger.info('New activity created with fresh push token');
await checkAndUpdateTimetable(
client: client,
studentName: studentName,
settingsStore: settingsStore
);
await scheduleBackgroundFetch();
} catch (e) {
_logger.severe('Error handling onAppOpened for LiveActivity: $e');
}
}
/// Called when user logs out
static Future<void> onUserLogout() async {
_logger.info('onUserLogout: Function called');
@@ -1350,7 +1281,12 @@ class LiveActivityService {
}
/// Starts a minimal placeholder activity shell - backend will update with real data
static Future<void> _startPlaceholderActivity(List<Lesson> allLessons, String studentName) async {
static Future<void> _startPlaceholderActivity(List<Lesson> allLessons, String studentName, {bool isBackground = false}) async {
if (isBackground) {
_logger.info('_startPlaceholderActivity: Called from background context, skipping to preserve existing activity');
return;
}
// Always end existing activities to ensure fresh token (8-hour expiration)
final activeActivities = await LiveActivityManager.getActiveActivities();
if (activeActivities.isNotEmpty) {
@@ -1454,10 +1390,8 @@ class LiveActivityService {
static Future<void> _clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_deviceTokenKey);
await prefs.remove(_lastTimetableUpdateKey);
await prefs.remove(_isRegisteredKey);
_cachedDeviceToken = null;
}
/// Try to get cached token or wait a short period until iOS provides it

View File

@@ -163,7 +163,7 @@ class SettingsStore {
],
0,
always, () async {
initLang(initData);
await initLang(initData);
initData.settings = SettingsStore(initData.l10n);
await initData.settings.load(initData.isar.appSettingsModels);

View File

@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:isar/isar.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../main.dart';
import 'active_account_helper.dart';
@@ -14,7 +15,17 @@ 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';
/// Invoke method with timeout to prevent infinite blocking
static Future<T?> _invokeMethodWithTimeout<T>(
@@ -181,6 +192,14 @@ class WatchSyncHelper {
return false;
}
static bool _isAccessTokenUsable(
DateTime? expiryDate, {
Duration skew = _tokenUsableSkew,
}) {
if (expiryDate == null) return false;
return expiryDate.isAfter(DateTime.now().add(skew));
}
static Map<String, dynamic> _buildTokenSyncPayload(
TokenModel token, {
bool includeSentAt = false,
@@ -221,6 +240,213 @@ class WatchSyncHelper {
));
}
static Future<bool> isWatchAppInstalled({bool forceRefresh = false}) async {
if (!Platform.isIOS) return false;
if (_watchAppInstalledCache && !forceRefresh) return true;
final now = DateTime.now();
if (!forceRefresh &&
_lastWatchInstallCheckAt != null &&
now.difference(_lastWatchInstallCheckAt!) <
_watchInstallCheckCooldown) {
return _watchAppInstalledCache;
}
_lastWatchInstallCheckAt = now;
final result = await _invokeMethodWithTimeout<bool>(
'isWatchAppInstalled',
null,
const Duration(seconds: 2),
);
_watchAppInstalledCache = result == true;
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(
'clearICloudToken',
null,
const Duration(seconds: 5),
);
if (notifyWatch) {
await notifyWatchForceLogout();
}
}
static Future<void> notifyWatchForceLogout() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled(forceRefresh: true);
if (!watchInstalled) return;
await _invokeMethodWithTimeout(
'sendLogoutToWatch',
null,
const Duration(seconds: 5),
);
}
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 {
if (!Platform.isIOS) return false;
final prefs = await SharedPreferences.getInstance();
final cleanupHandled = prefs.getBool(_iosFreshInstallHandledKey) ?? false;
if (cleanupHandled) {
return false;
}
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();
});
if (initDone) {
initData.tokens = [];
}
KretaClient.clearReauthFlag();
await prefs.setBool(_iosFreshInstallHandledKey, true);
return true;
}
static Future<dynamic> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'getTokenForWatch':
@@ -229,6 +455,8 @@ class WatchSyncHelper {
return _getLanguageForWatch();
case 'watchAppInstalled':
debugPrint('[WatchSync] Watch app installed detected');
_watchAppInstalledCache = true;
_lastWatchInstallCheckAt = DateTime.now();
return null;
case 'onTokenFromWatch':
debugPrint('[WatchSync] Token received from Watch');
@@ -238,11 +466,37 @@ class WatchSyncHelper {
'[WatchSync] Token recovered from iCloud notification received');
await _handleTokenRecoveredFromiCloud();
return null;
case 'onWatchMessage':
_handleWatchMessage(call.arguments);
return null;
default:
return null;
}
}
/// Callback for Watch pairing message events.
/// Set by main.dart to handle "ping" messages for Watch pairing flow.
static void Function(Map<String, dynamic> message)? onWatchMessage;
static void _handleWatchMessage(dynamic arguments) {
if (arguments == null) return;
try {
final Map<String, dynamic> message;
if (arguments is Map<String, dynamic>) {
message = arguments;
} else if (arguments is Map) {
message = Map<String, dynamic>.from(arguments);
} else {
debugPrint('[WatchSync] onWatchMessage: unexpected type ${arguments.runtimeType}');
return;
}
debugPrint('[WatchSync] Received Watch message: ${message["id"]}');
onWatchMessage?.call(message);
} catch (e) {
debugPrint('[WatchSync] Error handling Watch message: $e');
}
}
/// Called when iOS receives a fresh token from iCloud (e.g., Watch refreshed)
/// This clears the reauth flag if it was set, since we now have a valid token
static Future<void> _handleTokenRecoveredFromiCloud() async {
@@ -306,14 +560,28 @@ class WatchSyncHelper {
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');
return tokenData;
}
/// Send a fire-and-forget message to Watch via WatchSessionManager.
/// Replaces direct watch_connectivity plugin usage to avoid WCSession delegate conflict.
static Future<void> sendMessageToWatch(Map<String, dynamic> message) async {
if (!Platform.isIOS) return;
await _invokeMethodWithTimeout('sendMessageToWatch', message);
}
static Future<void> sendTokenToWatch() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) return;
final tokenData = _getTokenForWatch();
if (tokenData == null) return;
@@ -324,9 +592,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(
@@ -361,6 +635,11 @@ class WatchSyncHelper {
);
final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry);
if (!_isAccessTokenUsable(watchExpiryDate, skew: const Duration())) {
debugPrint(
'[WatchSync] Rejecting expired token from Watch, expiry: $watchExpiryDate');
return {'success': false, 'error': 'expired_token'};
}
final watchTokenVersion = _resolveIncomingTokenVersion(tokenData);
final watchUpdatedAtMs = _asInt(tokenData['updatedAtMs']);
final watchIdToken = tokenData['idToken'] as String?;
@@ -421,8 +700,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 ||
@@ -431,6 +718,17 @@ class WatchSyncHelper {
return;
}
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);
await _invokeMethodWithTimeout('sendTokenToWatch', tokenData);
@@ -439,8 +737,9 @@ class WatchSyncHelper {
static String? _getLanguageForWatch() {
if (!initDone) {
debugPrint('[WatchSync] App not initialized, returning default language');
return 'hu';
debugPrint(
'[WatchSync] App not initialized yet, language unavailable for Watch');
return null;
}
final languageCode = initData.l10n.localeName;
@@ -450,6 +749,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;
@@ -466,8 +770,13 @@ class WatchSyncHelper {
Isar? isar,
List<TokenModel>? tokens,
KretaClient? client,
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);
@@ -515,6 +824,13 @@ class WatchSyncHelper {
final iCloudExpiryDate =
DateTime.fromMillisecondsSinceEpoch(iCloudExpiry);
final iCloudAccessExpired =
!_isAccessTokenUsable(iCloudExpiryDate, skew: const Duration());
if (iCloudAccessExpired && !allowExpiredAccessToken) {
debugPrint(
'[WatchSync] iCloud token access is expired (expiry: $iCloudExpiryDate), skipping direct apply');
return false;
}
final iCloudTokenVersion = _resolveIncomingTokenVersion(tokenData);
final iCloudUpdatedAtMs = _asInt(tokenData['updatedAtMs']);
final iCloudIdToken = tokenData['idToken'] as String?;
@@ -569,8 +885,10 @@ class WatchSyncHelper {
effectiveClient.model = newToken;
}
if (expectedStudentIdNorm == null ||
newToken.studentIdNorm == expectedStudentIdNorm) {
final shouldClearReauth = !iCloudAccessExpired &&
(expectedStudentIdNorm == null ||
newToken.studentIdNorm == expectedStudentIdNorm);
if (shouldClearReauth) {
KretaClient.clearReauthFlag();
}
@@ -589,7 +907,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 ||
@@ -599,7 +920,15 @@ class WatchSyncHelper {
return;
}
final watchInstalled = await isWatchAppInstalled();
if (!watchInstalled) {
debugPrint(
'[WatchSync] Skipping iCloud token save because no paired Watch app is installed');
return;
}
final tokenData = _buildTokenSyncPayload(token);
tokenData['forceAccountSwitch'] = forceAccountSwitch;
await _invokeMethodWithTimeout(
'saveTokeToniCloud', tokenData, const Duration(seconds: 5));
@@ -643,7 +972,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;
}
@@ -658,7 +990,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;
}
@@ -684,12 +1019,32 @@ class WatchSyncHelper {
currentToken.refreshToken != null &&
currentToken.expiryDate != null &&
!KretaClient.needsReauth) {
await _sendTokenToWatchInternal(currentToken);
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
return;
}
final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry);
if (!_isAccessTokenUsable(watchExpiryDate, skew: const Duration())) {
debugPrint(
'[WatchSync] Watch provided expired token, ignoring and keeping iPhone token');
if (currentToken != null &&
currentToken.accessToken != null &&
currentToken.refreshToken != null &&
currentToken.expiryDate != null &&
_isAccessTokenUsable(currentToken.expiryDate,
skew: const Duration()) &&
!KretaClient.needsReauth) {
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
return;
}
final watchTokenVersion = _resolveIncomingTokenVersion(tokenData);
final watchUpdatedAtMs = _asInt(tokenData['updatedAtMs']);
final watchIdToken = tokenData['idToken'] as String?;
@@ -745,7 +1100,10 @@ class WatchSyncHelper {
} else {
debugPrint(
'[WatchSync] iPhone token is same or newer, sending to Watch');
await _sendTokenToWatchInternal(currentToken);
await _sendTokenToWatchInternal(
currentToken,
allowExpiredAccessToken: true,
);
}
} catch (e) {
debugPrint('[WatchSync] Failed to sync token from Watch: $e');

View File

@@ -29,7 +29,6 @@ import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:watch_connectivity/watch_connectivity.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'helpers/db/models/homework_cache_model.dart';
import 'helpers/update_notifier.dart';
@@ -190,7 +189,7 @@ void initTheme(AppInitialization data) {
Future<void> _initData(AppInitialization init) async {
await init.settings.load(init.isar.appSettingsModels);
initLang(init);
await initLang(init);
initTheme(init);
init.settings = SettingsStore(init.l10n);
await init.settings.load(init.isar.appSettingsModels);
@@ -202,17 +201,46 @@ Future<void> _initData(AppInitialization init) async {
initTheme(init);
};
dispatcher.onLocaleChanged = () {
final languageSetting =
init.settings.group("settings").subGroup("application")["language"]
as SettingsItemsRadio;
final isAutoLanguage = languageSetting.activeIndex == 0;
if (!isAutoLanguage) {
return;
}
final previousLocale = init.l10n.localeName;
unawaited(() async {
await initLang(init);
final nextLocale = init.l10n.localeName;
if (previousLocale != nextLocale) {
logger.info(
"[Init] System locale changed in auto mode: $previousLocale -> $nextLocale");
}
globalUpdate.update();
}());
};
resetOldTimeTableCache(init.isar);
resetOldHomeworkCache(init.isar);
var didRunFreshInstallCleanup = false;
if (Platform.isIOS) {
try {
await WatchSyncHelper.checkAndRecoverFromiCloud(
isar: init.isar,
tokens: init.tokens,
);
didRunFreshInstallCleanup =
await WatchSyncHelper.runFreshInstallCleanupIfNeeded(isar: init.isar);
if (didRunFreshInstallCleanup) {
logger.info(
'[Init] Fresh-install cleanup completed; skipping startup iCloud recovery on this launch');
} else {
await WatchSyncHelper.checkAndRecoverFromiCloud(
isar: init.isar,
tokens: init.tokens,
);
}
} catch (e) {
logger.warning('[Init] iCloud recovery check failed: $e');
logger.warning('[Init] iCloud bootstrap/recovery failed: $e');
}
}
@@ -236,6 +264,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) {
@@ -498,31 +532,38 @@ class InitializationScreen extends StatelessWidget {
FlutterNativeSplash.remove();
WatchSyncHelper.initialize();
var watch = WatchConnectivity();
if (Platform.isIOS) {
unawaited(() async {
try {
await WatchSyncHelper.sendLanguageToWatch();
} catch (e) {
logger.warning(
'[Init] Failed to publish language to Watch after sync init: $e');
}
}());
}
if (!initData.hasWatchListener) {
initData.hasWatchListener = true;
watch.messageStream.listen((e) {
var msg = e.entries.toMap();
WatchSyncHelper.onWatchMessage = (msg) {
logger.finest("WatchOS IPC [Watch -> Phone]: ${msg["id"]}");
switch (msg["id"]) {
case "ping":
if (initData.tokens.isNotEmpty) {
logger.finest("WatchOS IPC [Phone -> Watch]: pong");
watch.sendMessage({"id": "pong"});
const watchChannel = MethodChannel('app.firka/watch_sync');
watchChannel.invokeMethod('sendMessageToWatch', {"id": "pong"});
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) => HomeScreen(initData, true,
model: msg["model"] as String),
model: msg["model"] as String? ?? "unknown"),
),
);
}
}
});
};
}
if (snapshot.data!.tokens.isEmpty) {

View File

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

View File

@@ -77,7 +77,8 @@ UpdateNotifier subPageBack = UpdateNotifier();
HomePage homeScreenPage = HomePage.home;
List<HomePage> previousPages = List.empty(growable: true);
class _HomeScreenState extends FirkaState<HomeScreen> {
class _HomeScreenState extends FirkaState<HomeScreen>
with WidgetsBindingObserver {
_HomeScreenState();
final PageController _pageController = PageController();
@@ -257,9 +258,20 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
}
}
bool _prefetchInProgress = false;
bool _didRunLiveActivityLogin = false;
void prefetch() async {
if (_prefetched) return;
if (_prefetchInProgress) return;
final lifecycle = WidgetsBinding.instance.lifecycleState;
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) {
logger.info('[Home] prefetch: App is in background, deferring to foreground');
return;
}
_prefetchInProgress = true;
try {
_prefetched = true;
@@ -289,18 +301,21 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
await WidgetCacheHelper.refreshIOSWidgets(
widget.data.client, widget.data.settings);
final token = pickActiveToken(
tokens: widget.data.tokens,
settings: widget.data.settings,
);
final studentName = token?.studentId ?? "Student";
LiveActivityService.onUserLogin(
client: widget.data.client,
studentName: studentName,
settingsStore: widget.data.settings,
).catchError((e, st) {
logger.severe('LiveActivity registration failed: $e', e, st);
});
if (!_didRunLiveActivityLogin) {
_didRunLiveActivityLogin = true;
final token = pickActiveToken(
tokens: widget.data.tokens,
settings: widget.data.settings,
);
final studentName = token?.studentId ?? "Student";
LiveActivityService.onUserLogin(
client: widget.data.client,
studentName: studentName,
settingsStore: widget.data.settings,
).catchError((e, st) {
logger.severe('LiveActivity registration failed: $e', e, st);
});
}
}
if (!_disposed &&
@@ -396,6 +411,8 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
);
});
} finally {
_prefetchInProgress = false;
_hasCompletedFirstPrefetch = true;
if (!_disposed) {
setState(() {
_fetching = false;
@@ -471,11 +488,11 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
widget.data.settingsUpdateNotifier.addListener(settingsUpdateListener);
widget.data.profilePictureUpdateNotifier.addListener(() {
if (mounted) setState(() {});
});
widget.data.profilePictureUpdateNotifier.addListener(_onProfilePictureUpdated);
// Listen for reauth state changes (e.g., when Watch sends a valid token)
KretaClient.reauthStateNotifier.addListener(_onReauthStateChanged);
@@ -488,7 +505,7 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
if (Platform.isIOS &&
widget.data.settings.group("settings").boolean("beta_warning")) {
Future.delayed(Duration(seconds: 5), () async {
Future.delayed(Duration(seconds: 3), () async {
await LiveActivityService.showConsentScreenIfNeeded();
});
}
@@ -510,6 +527,10 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
if (mounted) setState(() {});
}
void _onProfilePictureUpdated() {
if (mounted) setState(() {});
}
Future<void> _preloadImages() async {
final imagePaths = widget.data.settings.appIcons.keys
.map((icon) => "assets/images/icons/$icon.webp")
@@ -857,16 +878,51 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
));
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed && !_disposed) {
logger.info('[Home] App resumed to foreground, re-running prefetch');
_prefetched = false;
_didRunSecondaryICloudRecovery = false;
prefetch();
if (Platform.isIOS) {
_refreshLiveActivityOnResume();
}
}
}
bool _hasCompletedFirstPrefetch = false;
void _refreshLiveActivityOnResume() async {
if (!_hasCompletedFirstPrefetch) {
logger.info('[Home] Skipping LiveActivity update: first prefetch not yet complete');
return;
}
try {
final token = pickActiveToken(
tokens: widget.data.tokens,
settings: widget.data.settings,
);
final studentName = token?.studentId ?? "Student";
await LiveActivityService.checkAndUpdateTimetable(
client: widget.data.client,
studentName: studentName,
settingsStore: widget.data.settings,
);
} catch (e) {
logger.warning('[Home] LiveActivity timetable update on resume failed: $e');
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pageController.dispose();
widget.data.settingsUpdateNotifier.removeListener(settingsUpdateListener);
widget.data.profilePictureUpdateNotifier
.removeListener(settingsUpdateListener);
widget.data.profilePictureUpdateNotifier.removeListener(() {
if (mounted) setState(() {});
});
.removeListener(_onProfilePictureUpdated);
// Remove reauth state listener
KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged);

View File

@@ -23,8 +23,10 @@ import 'package:url_launcher/url_launcher_string.dart';
import '../../../../helpers/db/widget.dart';
import '../../../../helpers/firka_bundle.dart';
import '../../../../helpers/firka_state.dart';
import '../../../../helpers/api/client/kreta_client.dart';
import '../../../../helpers/settings.dart';
import '../../../../helpers/live_activity_service.dart';
import '../../../../helpers/watch_sync_helper.dart';
import '../../widgets/login_webview.dart';
class SettingsScreen extends StatefulWidget {
@@ -149,12 +151,14 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
widgets.add(GestureDetector(
onTap: () {
if (item.redirectTo != null && item.redirectTo == "discord"){
launchUrlString("https://discord.com/invite/firka-1111649116020285532");
return;
} else if (item.redirectTo != null && item.redirectTo == "privacy"){
if (item.redirectTo != null && item.redirectTo == "discord") {
launchUrlString(
"https://discord.com/invite/firka-1111649116020285532");
return;
} else if (item.redirectTo != null &&
item.redirectTo == "privacy") {
launchUrlString("https://firka.app/privacy");
return;
return;
} else {
Navigator.push(
context,
@@ -164,10 +168,18 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
child: SettingsScreen(widget.data, item.children))));
}
},
child: item.redirectTo != null
? FirkaCard(left: cardWidgets, right: [RotationTransition(turns: AlwaysStoppedAnimation(-45/360), child: FirkaIconWidget(FirkaIconType.majesticons, Majesticon.arrowRightSolid, size: 24, color: appStyle.colors.textSecondary))],)
: FirkaCard(left: cardWidgets),
child: item.redirectTo != null
? FirkaCard(
left: cardWidgets,
right: [
RotationTransition(
turns: AlwaysStoppedAnimation(-45 / 360),
child: FirkaIconWidget(FirkaIconType.majesticons,
Majesticon.arrowRightSolid,
size: 24, color: appStyle.colors.textSecondary))
],
)
: FirkaCard(left: cardWidgets),
));
continue;
@@ -177,28 +189,24 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
var v = item.toRoundedString();
widgets.add(GestureDetector(
child: FirkaCard(
height: 52 + 12,
left: [
item.iconType != null
? Row(
children: [
FirkaIconWidget(item.iconType!, item.iconData!,
color: appStyle.colors.accent),
SizedBox(width: 4),
],
)
: SizedBox(),
Text(item.title,
style: appStyle.fonts.B_16SB
.apply(color: appStyle.colors.textPrimary))
],
right: [
Text(v == "0.0" ? "0" : v,
style: appStyle.fonts.B_16R
.apply(color: appStyle.colors.textPrimary))
]
),
child: FirkaCard(height: 52 + 12, left: [
item.iconType != null
? Row(
children: [
FirkaIconWidget(item.iconType!, item.iconData!,
color: appStyle.colors.accent),
SizedBox(width: 4),
],
)
: SizedBox(),
Text(item.title,
style: appStyle.fonts.B_16SB
.apply(color: appStyle.colors.textPrimary))
], right: [
Text(v == "0.0" ? "0" : v,
style: appStyle.fonts.B_16R
.apply(color: appStyle.colors.textPrimary))
]),
onTap: () async {
showSetDoubleSheet(context, item, widget.data, setState);
},
@@ -212,12 +220,12 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
left: [
item.iconType != null
? Row(
children: [
FirkaIconWidget(item.iconType!, item.iconData!,
color: appStyle.colors.accent),
SizedBox(width: 4),
],
)
children: [
FirkaIconWidget(item.iconType!, item.iconData!,
color: appStyle.colors.accent),
SizedBox(width: 4),
],
)
: SizedBox(),
Text(item.title,
style: appStyle.fonts.B_16SB
@@ -316,67 +324,74 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
continue;
}
if (item is ShowLicensePage) {
widgets.add(
FutureBuilder<List<LicenseEntry>>(
future: LicenseRegistry.licenses.toList(),
builder: (BuildContext context, AsyncSnapshot<List<LicenseEntry>> snapshot) {
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator(color: appStyle.colors.accent));
}
final licenses = snapshot.data!;
final shownPackages = <String>{};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: licenses.where((license) {
return license.packages.any((pkg) => !shownPackages.contains(pkg));
}).map((license) {
final packageName = license.packages.firstWhere(
(pkg) => !shownPackages.contains(pkg),
orElse: () => license.packages.first,
);
shownPackages.add(packageName);
final paragraphs = license.paragraphs.map((p) => p.text).join('\n\n');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(packageName, style: TextStyle(fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Text(paragraphs),
),
actions: [
TextButton(
child: Text(widget.data.l10n.close),
onPressed: () {
Navigator.of(context).pop();
},
widgets.add(FutureBuilder<List<LicenseEntry>>(
future: LicenseRegistry.licenses.toList(),
builder: (BuildContext context,
AsyncSnapshot<List<LicenseEntry>> snapshot) {
if (!snapshot.hasData) {
return Center(
child:
CircularProgressIndicator(color: appStyle.colors.accent));
}
final licenses = snapshot.data!;
final shownPackages = <String>{};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: licenses.where((license) {
return license.packages
.any((pkg) => !shownPackages.contains(pkg));
}).map((license) {
final packageName = license.packages.firstWhere(
(pkg) => !shownPackages.contains(pkg),
orElse: () => license.packages.first,
);
shownPackages.add(packageName);
final paragraphs =
license.paragraphs.map((p) => p.text).join('\n\n');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(packageName,
style:
TextStyle(fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Text(paragraphs),
),
],
);
},
);
},
child: FirkaCard(left: [
Text(
packageName,
style: appStyle.fonts.B_14R.apply(color: appStyle.colors.textPrimary),
),
]),
)
],
),
);}).toList(),
);
},
)
);
actions: [
TextButton(
child: Text(widget.data.l10n.close),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
},
child: FirkaCard(left: [
Text(
packageName,
style: appStyle.fonts.B_14R
.apply(color: appStyle.colors.textPrimary),
),
]),
)
],
),
);
}).toList(),
);
},
));
continue;
}
@@ -647,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 {
@@ -659,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());
}
},
@@ -710,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);
@@ -718,17 +797,61 @@ class _SettingsScreenState extends FirkaState<SettingsScreen> {
await item.save(widget.data.isar.appSettingsModels);
});
final accounts =
await widget.data.isar.tokenModels.where().findAll();
if (accounts.isEmpty) {
if (Platform.isIOS) {
try {
await WatchSyncHelper.clearICloudToken(notifyWatch: true);
await WatchSyncHelper.clearAllRefreshLeases();
} catch (e) {
logger.warning('[Settings] Failed to clear iCloud token: $e');
}
KretaClient.clearReauthFlag();
}
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) => LoginScreen(widget.data)),
(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());
}
@@ -919,7 +1042,10 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting,
value: setting.value,
max: setting.maxValue,
divisions: setting.step != null
? ((setting.maxValue - setting.minValue) / setting.step!).round()
? ((setting.maxValue -
setting.minValue) /
setting.step!)
.round()
: null,
thumbColor: appStyle.colors.accent,
activeColor: appStyle.colors.secondary,
@@ -927,7 +1053,9 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting,
onChanged: (v) async {
setState(() {
if (setting.step != null) {
setting.value = (v / setting.step!).round() * setting.step!;
setting.value =
(v / setting.step!).round() *
setting.step!;
} else {
setting.value = v;
}

View File

@@ -90,14 +90,18 @@ class _LoginWebviewWidgetState extends FirkaState<LoginWebviewWidget> {
await accountPicker.postUpdate();
if (Platform.isIOS) {
try {
await WatchSyncHelper.saveTokenToiCloud(tokenModel);
} catch (_) {}
final watchInstalled =
await WatchSyncHelper.isWatchAppInstalled();
if (watchInstalled) {
try {
await WatchSyncHelper.saveTokenToiCloud(tokenModel);
} catch (_) {}
try {
await WatchSyncHelper.sendTokenToWatch();
} catch (e) {
// Watch may not be available, ignore
try {
await WatchSyncHelper.sendTokenToWatch();
} catch (_) {
// Watch may be unavailable, ignore
}
}
}