forked from firka/firka
Multiple fixes and improvements for watch <> phone sync, token recovery, and live activity behavior: - WatchSessionManager: add mergeApplicationContext to avoid clobbering app context, add thread-safe pending auth queue and flush, add sendMessageToWatch API, ensure message handling runs on main thread, add reply-timeout logic for language requests, support fire-and-forget messages, and improve enqueue/flush logic. - WatchConnectivityManager & SettingsView: publish shared session state on force-logout/logout and improve account-switch/token handling; clear DataStore error and reset recovery state after token updates. - DataStore & WatchL10n: add recovery-in-progress guard to avoid duplicate recovery runs, reset language version tracking on account switch, make WatchL10n.setLanguage main-thread safe. - TokenManager & SharedKeychainManager: remove old keychain observer plumbing and instead publish shared session state when active token changes or is deleted. - UI tweaks: reduce icon/text sizes and spacing in pairing view; only show sync button when paired; Settings logout now also publishes shared state. - Watch sync wiring in Flutter: replace direct watch_connectivity usage with a MethodChannel-backed WatchSyncHelper.sendMessageToWatch and onWatchMessage callback; main and pairing UI updated accordingly. - Kreta client: replace simple boolean mutex with a Completer-based mutex and timeout handling to avoid busy-waiting. - LiveActivityService: throttle/avoid frequent activity recreation (cache last recreation time), skip placeholder creation when called from background, and minor cache-clearing adjustments. - HomeScreen: add WidgetsBindingObserver to manage lifecycle, prevent prefetch while backgrounded, debounce prefetch, ensure LiveActivity registration runs once and refresh on resume after first prefetch. These changes increase robustness of token sync and account switching, reduce race conditions and duplicate work, and avoid conflicts between WCSession and Flutter plugin delegates.
457 lines
17 KiB
Swift
457 lines
17 KiB
Swift
import Foundation
|
|
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()
|
|
}
|
|
|
|
private var lastAppliedTokenUpdateMs: Int64 {
|
|
get {
|
|
Int64(UserDefaults.standard.double(forKey: lastAppliedTokenUpdateKey))
|
|
}
|
|
set {
|
|
UserDefaults.standard.set(Double(newValue), forKey: lastAppliedTokenUpdateKey)
|
|
}
|
|
}
|
|
|
|
private func extractSentAtMs(from authDict: [String: Any]) -> Int64? {
|
|
if let value = authDict["sentAtMs"] as? Int64 {
|
|
return value
|
|
}
|
|
if let value = authDict["sentAtMs"] as? Int {
|
|
return Int64(value)
|
|
}
|
|
if let value = authDict["sentAtMs"] as? Double {
|
|
return Int64(value)
|
|
}
|
|
if let value = authDict["sentAtMs"] as? String,
|
|
let parsed = Int64(value) {
|
|
return parsed
|
|
}
|
|
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() {
|
|
print("[Watch] WCSession is supported, activating...")
|
|
WCSession.default.delegate = self
|
|
WCSession.default.activate()
|
|
} else {
|
|
print("[Watch] WCSession is NOT supported!")
|
|
}
|
|
}
|
|
|
|
func session(
|
|
_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: Error?
|
|
) {
|
|
print("[Watch] Session activation completed with state: \(activationState.rawValue)")
|
|
if let error = error {
|
|
print("[Watch] Activation error: \(error.localizedDescription)")
|
|
}
|
|
DispatchQueue.main.async {
|
|
if activationState == .activated {
|
|
let context = session.receivedApplicationContext
|
|
if !context.isEmpty {
|
|
self.processApplicationContext(context)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func session(
|
|
_ session: WCSession,
|
|
didReceiveApplicationContext applicationContext: [String: Any]
|
|
) {
|
|
print("[Watch] didReceiveApplicationContext called")
|
|
DispatchQueue.main.async {
|
|
self.processApplicationContext(applicationContext)
|
|
}
|
|
}
|
|
|
|
func session(
|
|
_ session: WCSession,
|
|
didReceiveUserInfo userInfo: [String: Any] = [:]
|
|
) {
|
|
print("[Watch] didReceiveUserInfo called")
|
|
DispatchQueue.main.async {
|
|
self.processUserInfo(userInfo)
|
|
}
|
|
}
|
|
|
|
func session(
|
|
_ session: WCSession,
|
|
didReceiveMessage message: [String: Any],
|
|
replyHandler: @escaping ([String: Any]) -> Void
|
|
) {
|
|
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
|
|
}
|
|
|
|
switch action {
|
|
case "getToken":
|
|
handleGetTokenRequest(replyHandler: replyHandler)
|
|
default:
|
|
replyHandler(["error": "unknown_action"])
|
|
}
|
|
}
|
|
|
|
private func handleGetTokenRequest(replyHandler: @escaping ([String: Any]) -> Void) {
|
|
guard TokenManager.shared.loadToken() != nil else {
|
|
print("[Watch] No token to send to iPhone")
|
|
replyHandler(["error": "no_token"])
|
|
return
|
|
}
|
|
|
|
if TokenManager.shared.isTokenExpired() {
|
|
print("[Watch] Token expired, attempting refresh before sending to iPhone...")
|
|
Task {
|
|
do {
|
|
let freshToken = try await KretaAPIClient.shared.getValidToken()
|
|
print("[Watch] Token refresh succeeded, sending fresh token to iPhone")
|
|
|
|
var tokenData: [String: Any] = [
|
|
"studentId": freshToken.studentId,
|
|
"studentIdNorm": freshToken.studentIdNorm,
|
|
"iss": freshToken.iss,
|
|
"idToken": freshToken.idToken,
|
|
"accessToken": freshToken.accessToken,
|
|
"refreshToken": freshToken.refreshToken,
|
|
"expiryDate": Int64(freshToken.expiryDate.timeIntervalSince1970 * 1000)
|
|
]
|
|
if let tokenVersion = freshToken.effectiveTokenVersion {
|
|
tokenData["tokenVersion"] = tokenVersion
|
|
}
|
|
tokenData["updatedAtMs"] = freshToken.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
|
|
|
replyHandler(["token": tokenData])
|
|
} catch {
|
|
print("[Watch] Token refresh failed after all retries: \(error)")
|
|
replyHandler(["error": "refresh_failed"])
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let token = TokenManager.shared.loadToken() else {
|
|
replyHandler(["error": "no_token"])
|
|
return
|
|
}
|
|
|
|
var tokenData: [String: Any] = [
|
|
"studentId": token.studentId,
|
|
"studentIdNorm": token.studentIdNorm,
|
|
"iss": token.iss,
|
|
"idToken": token.idToken,
|
|
"accessToken": token.accessToken,
|
|
"refreshToken": token.refreshToken,
|
|
"expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000)
|
|
]
|
|
if let tokenVersion = token.effectiveTokenVersion {
|
|
tokenData["tokenVersion"] = tokenVersion
|
|
}
|
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm:ss"
|
|
formatter.timeZone = TimeZone.current
|
|
print("[Watch] Sending token to iPhone, expiry: \(formatter.string(from: token.expiryDate))")
|
|
replyHandler(["token": tokenData])
|
|
}
|
|
|
|
func requestTokenFromPhone() {
|
|
guard WCSession.default.activationState == .activated else {
|
|
print("[Watch] Cannot request token: session not activated")
|
|
return
|
|
}
|
|
|
|
guard WCSession.default.isReachable else {
|
|
print("[Watch] Cannot request token: iPhone not reachable")
|
|
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(
|
|
["action": "requestToken"],
|
|
replyHandler: { response in
|
|
print("[Watch] Received response from iPhone")
|
|
DispatchQueue.main.async {
|
|
if let authDict = response["auth"] as? [String: Any] {
|
|
print("[Watch] Token received from iPhone")
|
|
self.processAuthData(authDict)
|
|
} else if let error = response["error"] as? String {
|
|
print("[Watch] Token request error: \(error)")
|
|
}
|
|
}
|
|
},
|
|
errorHandler: { error in
|
|
print("[Watch] Token request failed: \(error.localizedDescription)")
|
|
}
|
|
)
|
|
}
|
|
|
|
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,
|
|
sharedStateVersion: sharedStateVersion
|
|
)
|
|
}
|
|
}
|
|
|
|
private func processUserInfo(_ userInfo: [String: Any]) {
|
|
if let messageId = userInfo["id"] as? String {
|
|
switch messageId {
|
|
case "token_update":
|
|
if let authDict = userInfo["auth"] as? [String: Any] {
|
|
print("[Watch] Received token_update via userInfo")
|
|
processAuthData(authDict)
|
|
}
|
|
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,
|
|
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")
|
|
return
|
|
}
|
|
|
|
guard let token = TokenManager.shared.loadToken() else {
|
|
print("[Watch] No token to send to iPhone")
|
|
return
|
|
}
|
|
|
|
var tokenData: [String: Any] = [
|
|
"studentId": token.studentId,
|
|
"studentIdNorm": token.studentIdNorm,
|
|
"iss": token.iss,
|
|
"idToken": token.idToken,
|
|
"accessToken": token.accessToken,
|
|
"refreshToken": token.refreshToken,
|
|
"expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000)
|
|
]
|
|
if let tokenVersion = token.effectiveTokenVersion {
|
|
tokenData["tokenVersion"] = tokenVersion
|
|
}
|
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
|
|
|
do {
|
|
try WCSession.default.updateApplicationContext(["auth": tokenData])
|
|
print("[Watch] Token sent via applicationContext")
|
|
} catch {
|
|
print("[Watch] Failed to update applicationContext: \(error)")
|
|
}
|
|
|
|
WCSession.default.transferUserInfo([
|
|
"id": "token_update_from_watch",
|
|
"auth": tokenData
|
|
])
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm:ss"
|
|
formatter.timeZone = TimeZone.current
|
|
print("[Watch] Token sent to iPhone (background), expiry: \(formatter.string(from: token.expiryDate))")
|
|
}
|
|
|
|
func requestLanguageFromPhone() {
|
|
guard WCSession.default.activationState == .activated else {
|
|
print("[Watch] Cannot request language: session not activated")
|
|
return
|
|
}
|
|
|
|
guard WCSession.default.isReachable else {
|
|
print("[Watch] Cannot request language: iPhone not reachable")
|
|
return
|
|
}
|
|
|
|
print("[Watch] Requesting language from iPhone...")
|
|
|
|
WCSession.default.sendMessage(
|
|
["action": "requestLanguage"],
|
|
replyHandler: { response in
|
|
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,
|
|
sharedStateVersion: sharedStateVersion
|
|
)
|
|
}
|
|
}
|
|
},
|
|
errorHandler: { error in
|
|
print("[Watch] Language request failed: \(error.localizedDescription)")
|
|
}
|
|
)
|
|
}
|
|
|
|
private func processAuthData(_ authDict: [String: Any]) {
|
|
print("[Watch] processAuthData called")
|
|
do {
|
|
let incomingSentAtMs = extractSentAtMs(from: authDict) ?? 0
|
|
let previousSentAtMs = lastAppliedTokenUpdateMs
|
|
|
|
if incomingSentAtMs > 0 && incomingSentAtMs < previousSentAtMs {
|
|
print("[Watch] Ignoring stale token_update (sentAtMs: \(incomingSentAtMs), lastApplied: \(previousSentAtMs))")
|
|
return
|
|
}
|
|
|
|
let jsonData = try JSONSerialization.data(withJSONObject: authDict)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .custom { decoder in
|
|
let container = try decoder.singleValueContainer()
|
|
let timestamp = try container.decode(Int64.self)
|
|
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
|
}
|
|
|
|
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 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 (same account, not newer)")
|
|
return
|
|
}
|
|
|
|
print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs), forceSwitch: \(shouldForceAccountSwitch))")
|
|
|
|
try TokenManager.shared.saveToken(
|
|
token,
|
|
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 {
|
|
await DataStore.shared.refreshAllWithRecovery()
|
|
print("[Watch] Data refresh completed")
|
|
}
|
|
} catch {
|
|
print("[Watch] Failed to process auth data: \(error)")
|
|
}
|
|
}
|
|
}
|