forked from firka/firka
Compare commits
10 Commits
8f28fa328c
...
dd3884de16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3884de16 | ||
|
|
c646ea2d51 | ||
|
|
a22459794a | ||
|
|
91a526703e | ||
|
|
38ff8af578 | ||
|
|
58c16e9aa8 | ||
|
|
748bff63ea | ||
|
|
812c1a008e | ||
|
|
b71aa12751 | ||
|
|
c16cbdb186 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -289,7 +289,7 @@ struct ReauthRequiredView: View {
|
||||
|
||||
try TokenManager.shared.saveToken(
|
||||
token,
|
||||
syncToICloud: false,
|
||||
syncToSharedKeychain: false,
|
||||
forceAccountSwitch: shouldForceAccountSwitch
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ struct SettingsView: View {
|
||||
|
||||
private func logout() {
|
||||
TokenManager.shared.deleteToken()
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: false,
|
||||
activeStudentIdNorm: nil
|
||||
)
|
||||
DataStore.shared.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1068</string>
|
||||
<string>1101</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
791
firka/ios/Shared/API/SharedKeychainManager.swift
Normal file
791
firka/ios/Shared/API/SharedKeychainManager.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user