diff --git a/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements b/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements index af013c5..a96138b 100644 --- a/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements +++ b/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements @@ -7,6 +7,6 @@ group.app.firka.firkaa com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)$(CFBundleIdentifier) + $(TeamIdentifierPrefix)app.firka.firkaa diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index bf65526..6508221 100644 --- a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -174,13 +174,27 @@ class DataStore { } print("[Watch] Recovery Step 2: Attempting API token refresh...") + var isNetworkError = false + var isTokenPermanentlyInvalid = false + do { _ = try await TokenManager.shared.refreshToken() print("[Watch] Recovery: Token refresh succeeded!") checkTokenState() return true + } catch let tokenError as TokenError { + print("[Watch] Recovery: API token refresh failed: \(tokenError)") + switch tokenError { + case .networkError: + isNetworkError = true + case .refreshExpired, .invalidGrant: + isTokenPermanentlyInvalid = true + default: + break + } } catch { - print("[Watch] Recovery: API token refresh failed: \(error)") + print("[Watch] Recovery: API token refresh failed with unknown error: \(error)") + isNetworkError = true // Assume network issue for unknown errors } print("[Watch] Recovery Step 3: Checking if iPhone is reachable...") @@ -190,9 +204,19 @@ class DataStore { return true } - print("[Watch] Recovery: All attempts failed, will show reauth screen") - recoveryAttempted = true - self.error = "token_expired" + if isTokenPermanentlyInvalid { + print("[Watch] Recovery: Token permanently invalid, showing reauth screen") + recoveryAttempted = true + self.error = "token_expired" + } else if isNetworkError { + print("[Watch] Recovery: Network error - not showing reauth, user can retry") + self.error = "network" + } else { + print("[Watch] Recovery: Unknown failure, showing reauth screen") + recoveryAttempted = true + self.error = "token_expired" + } + return false } diff --git a/firka/ios/FirkaWatchComplicationsExtension.entitlements b/firka/ios/FirkaWatchComplicationsExtension.entitlements index af013c5..a96138b 100644 --- a/firka/ios/FirkaWatchComplicationsExtension.entitlements +++ b/firka/ios/FirkaWatchComplicationsExtension.entitlements @@ -7,6 +7,6 @@ group.app.firka.firkaa com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)$(CFBundleIdentifier) + $(TeamIdentifierPrefix)app.firka.firkaa diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj index d97857b..d04c664 100644 --- a/firka/ios/Runner.xcodeproj/project.pbxproj +++ b/firka/ios/Runner.xcodeproj/project.pbxproj @@ -1076,7 +1076,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1108,7 +1108,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests; @@ -1127,7 +1127,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests; @@ -1144,7 +1144,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests; @@ -1171,7 +1171,7 @@ CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1222,7 +1222,7 @@ CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1270,7 +1270,7 @@ CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1317,7 +1317,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1370,7 +1370,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1420,7 +1420,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1471,7 +1471,7 @@ CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1522,7 +1522,7 @@ CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1569,7 +1569,7 @@ CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1874,7 +1874,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -1910,7 +1910,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1062; + CURRENT_PROJECT_VERSION = 1068; DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/firka/ios/Runner/Info.plist b/firka/ios/Runner/Info.plist index 069b100..338316a 100644 --- a/firka/ios/Runner/Info.plist +++ b/firka/ios/Runner/Info.plist @@ -46,7 +46,7 @@ CFBundleVersion - 1062 + 1068 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/firka/ios/Runner/Runner.entitlements b/firka/ios/Runner/Runner.entitlements index 17ab594..48392c5 100644 --- a/firka/ios/Runner/Runner.entitlements +++ b/firka/ios/Runner/Runner.entitlements @@ -11,6 +11,6 @@ group.app.firka.firkaa com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)$(CFBundleIdentifier) + $(TeamIdentifierPrefix)app.firka.firkaa diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift index a2b1771..540d928 100644 --- a/firka/ios/Runner/WatchSessionManager.swift +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -29,6 +29,10 @@ class WatchSessionManager: NSObject, WCSessionDelegate { self?.handleNotifyReauthRequired(result: result) case "requestTokenFromWatch": self?.handleRequestTokenFromWatch(result: result) + case "checkiCloudToken": + self?.handleCheckiCloudToken(result: result) + case "saveTokeToniCloud": + self?.handleSaveTokenToiCloud(arguments: call.arguments, result: result) default: result(FlutterMethodNotImplemented) } @@ -151,6 +155,70 @@ class WatchSessionManager: NSObject, WCSessionDelegate { ) } + private func handleCheckiCloudToken(result: @escaping FlutterResult) { + print("[WatchSessionManager] Checking iCloud for token...") + + guard let token = iCloudTokenManager.shared.loadToken() else { + print("[WatchSessionManager] No token in iCloud") + result(["error": "no_token"]) + return + } + + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + print("[WatchSessionManager] Found iCloud token, expiry: \(formatter.string(from: token.expiryDate))") + + let 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) + ] + + result(tokenData) + } + + private func handleSaveTokenToiCloud(arguments: Any?, result: @escaping FlutterResult) { + guard let tokenData = arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil)) + return + } + + guard let accessToken = tokenData["accessToken"] as? String, + let refreshToken = tokenData["refreshToken"] as? String, + let idToken = tokenData["idToken"] as? String, + let iss = tokenData["iss"] as? String, + let studentId = tokenData["studentId"] as? String, + let expiryMs = tokenData["expiryDate"] as? Int64 else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing required token fields", details: nil)) + return + } + + let studentIdNorm = tokenData["studentIdNorm"] as? Int64 ?? 0 + let expiryDate = Date(timeIntervalSince1970: Double(expiryMs) / 1000.0) + + let token = WatchToken( + accessToken: accessToken, + refreshToken: refreshToken, + idToken: idToken, + iss: iss, + studentId: studentId, + studentIdNorm: studentIdNorm, + expiryDate: expiryDate + ) + + iCloudTokenManager.shared.saveToken(token, deviceName: "iPhone") + + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + print("[WatchSessionManager] Token saved to iCloud, expiry: \(formatter.string(from: expiryDate))") + + result(nil) + } + func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index 1e8fa32..8d98e10 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -94,6 +94,16 @@ class KretaClient { if (!Platform.isIOS || !initDone) return false; try { + final recoveredFromiCloud = await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: initData.isar, + tokens: initData.tokens, + client: initData.client, + ); + if (recoveredFromiCloud) { + debugPrint('[KretaClient] Recovered fresh token from iCloud'); + return true; + } + await WatchSyncHelper.syncTokenFromWatch( isar: initData.isar, tokens: initData.tokens, @@ -113,7 +123,7 @@ class KretaClient { return false; } catch (e) { - debugPrint('[KretaClient] Failed to recover from Watch: $e'); + debugPrint('[KretaClient] Failed to recover from Watch/iCloud: $e'); return false; } } @@ -126,7 +136,25 @@ class KretaClient { final fiveMinutesFromNow = now.add(const Duration(minutes: 5)); if (model.expiryDate == null || model.expiryDate!.isBefore(fiveMinutesFromNow)) { - logger.info("[Proactive] Token expired or expiring soon, refreshing proactively..."); + logger.info("[Proactive] Token expired or expiring soon..."); + + if (Platform.isIOS && initDone) { + final recoveredFromiCloud = await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: isar, + tokens: initData.tokens, + client: this, + ); + if (recoveredFromiCloud) { + logger.info("[Proactive] Found fresh token in iCloud, no refresh needed"); + initData.tokens = await isar.tokenModels.where().findAll(); + if (initData.tokens.isNotEmpty) { + model = initData.tokens.first; + } + return true; + } + } + + logger.info("[Proactive] No fresh token in iCloud, refreshing..."); try { var extended = await extendToken(model); @@ -140,6 +168,12 @@ class KretaClient { model = tokenModel; if (Platform.isIOS) { + try { + await WatchSyncHelper.saveTokenToiCloud(tokenModel); + } catch (e) { + debugPrint('[KretaClient] iCloud token sync skipped: $e'); + } + try { await _watchChannel.invokeMethod('sendTokenToWatch', { 'studentId': model.studentId, @@ -207,6 +241,12 @@ class KretaClient { model = tokenModel; if (Platform.isIOS) { + try { + await WatchSyncHelper.saveTokenToiCloud(tokenModel); + } catch (e) { + debugPrint('[KretaClient] iCloud token sync skipped: $e'); + } + try { await _watchChannel.invokeMethod('sendTokenToWatch', { 'studentId': model.studentId, diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index 5c6a415..34fd072 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -194,6 +194,126 @@ class WatchSyncHelper { } } + /// Check iCloud for a fresher token and update local storage if found. + /// This should be called on app startup BEFORE any API calls. + /// Returns true if a fresher token was found and applied. + static Future checkAndRecoverFromiCloud({ + Isar? isar, + List? tokens, + KretaClient? client, + }) async { + if (!Platform.isIOS) return false; + + final effectiveIsar = isar ?? (initDone ? initData.isar : null); + final effectiveTokens = tokens ?? (initDone ? initData.tokens : null); + final effectiveClient = client ?? (initDone ? initData.client : null); + + if (effectiveIsar == null) { + debugPrint('[WatchSync] Cannot check iCloud: no isar available'); + return false; + } + + try { + debugPrint('[WatchSync] Checking iCloud for fresher token...'); + final result = await _watchChannel.invokeMethod('checkiCloudToken'); + + if (result == null) { + debugPrint('[WatchSync] No response from native'); + return false; + } + + final tokenData = result as Map; + if (tokenData.containsKey('error')) { + debugPrint('[WatchSync] iCloud check returned: ${tokenData['error']}'); + return false; + } + + final iCloudExpiry = tokenData['expiryDate'] as int?; + if (iCloudExpiry == null) { + debugPrint('[WatchSync] iCloud token has no expiry'); + return false; + } + + final iCloudExpiryDate = DateTime.fromMillisecondsSinceEpoch(iCloudExpiry); + + if (iCloudExpiryDate.isBefore(DateTime.now())) { + debugPrint('[WatchSync] iCloud token is expired'); + return false; + } + + final currentToken = effectiveTokens?.isNotEmpty == true ? effectiveTokens!.first : null; + final localExpiry = currentToken?.expiryDate; + + if (localExpiry == null || iCloudExpiryDate.isAfter(localExpiry)) { + debugPrint('[WatchSync] iCloud has fresher token! iCloud: $iCloudExpiryDate, Local: $localExpiry'); + + final newToken = TokenModel.fromValues( + (tokenData['studentIdNorm'] as int?) ?? 0, + tokenData['studentId'] as String, + tokenData['iss'] as String, + tokenData['idToken'] as String, + tokenData['accessToken'] as String, + tokenData['refreshToken'] as String, + iCloudExpiry, + ); + + await effectiveIsar.writeTxn(() async { + await effectiveIsar.tokenModels.put(newToken); + }); + + final updatedTokens = await effectiveIsar.tokenModels.where().findAll(); + + if (initDone) { + initData.tokens = updatedTokens; + } + + if (effectiveClient != null) { + effectiveClient.model = newToken; + } + + KretaClient.clearReauthFlag(); + + debugPrint('[WatchSync] Token recovered from iCloud! New expiry: $iCloudExpiryDate'); + return true; + } else { + debugPrint('[WatchSync] Local token is same or fresher. Local: $localExpiry, iCloud: $iCloudExpiryDate'); + return false; + } + } catch (e) { + debugPrint('[WatchSync] Failed to check iCloud: $e'); + return false; + } + } + + /// Save token to iCloud. Call this after refreshing token on iPhone. + static Future saveTokenToiCloud(TokenModel token) async { + if (!Platform.isIOS) return; + + if (token.accessToken == null || + token.refreshToken == null || + token.expiryDate == null) { + debugPrint('[WatchSync] Token incomplete, not saving to iCloud'); + return; + } + + final tokenData = { + 'studentId': token.studentId, + 'studentIdNorm': token.studentIdNorm, + 'iss': token.iss, + 'idToken': token.idToken, + 'accessToken': token.accessToken, + 'refreshToken': token.refreshToken, + 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, + }; + + try { + await _watchChannel.invokeMethod('saveTokeToniCloud', tokenData); + debugPrint('[WatchSync] Token saved to iCloud'); + } catch (e) { + debugPrint('[WatchSync] Failed to save token to iCloud: $e'); + } + } + static Future syncTokenFromWatch({ Isar? isar, List? tokens, diff --git a/firka/lib/main.dart b/firka/lib/main.dart index edff368..9d70e27 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -215,10 +215,21 @@ Future _initData(AppInitialization init) async { logger.fine("Initializing kréta client as: ${token.studentId}"); init.client = KretaClient(token, init.isar); - // Sync token from Watch first (Watch might have fresher token) if (Platform.isIOS) { - await Future.delayed(const Duration(milliseconds: 300)); + final recoveredFromiCloud = await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: init.isar, + tokens: init.tokens, + client: init.client, + ); + if (recoveredFromiCloud) { + init.tokens = await init.isar.tokenModels.where().findAll(); + if (init.tokens.isNotEmpty) { + init.client.model = init.tokens.first; + } + logger.info('[Init] Recovered fresher token from iCloud'); + } + await Future.delayed(const Duration(milliseconds: 300)); await WatchSyncHelper.syncTokenFromWatch( isar: init.isar, tokens: init.tokens,