Add iCloud token sync & improve recovery

Add iCloud-based token recovery and saving across iOS/watch components. Introduces WatchSyncHelper.checkAndRecoverFromiCloud and saveTokenToiCloud to fetch/save fresher tokens via a method channel, calls the check on app init and before proactive refresh, and attempts to save refreshed tokens to iCloud. Adds native handlers in WatchSessionManager (checkiCloudToken, saveTokeToniCloud) and updates DataStore token recovery to distinguish network errors vs permanently invalid tokens (avoid unnecessary reauth). Update logging messages accordingly. Also update ubiquity-kvstore identifiers in entitlements to use explicit group.app.firka.firkaa and bump iOS build/version (CURRENT_PROJECT_VERSION and CFBundleVersion) to 1068.
This commit is contained in:
Horváth Gergely
2026-02-08 16:18:28 +01:00
parent 0f3dcf58a5
commit b9de46f0ed
10 changed files with 290 additions and 27 deletions

View File

@@ -7,6 +7,6 @@
<string>group.app.firka.firkaa</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
</dict>
</plist>

View File

@@ -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
}

View File

@@ -7,6 +7,6 @@
<string>group.app.firka.firkaa</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
</dict>
</plist>

View File

@@ -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;

View File

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

View File

@@ -11,6 +11,6 @@
<string>group.app.firka.firkaa</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
</dict>
</plist>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<bool> checkAndRecoverFromiCloud({
Isar? isar,
List<TokenModel>? 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<dynamic, dynamic>;
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<void> 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<void> syncTokenFromWatch({
Isar? isar,
List<TokenModel>? tokens,

View File

@@ -215,10 +215,21 @@ Future<void> _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,