diff --git a/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements b/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements
index a96138b9..b76b3dad 100644
--- a/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements
+++ b/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements
@@ -6,6 +6,10 @@
group.app.firka.firkaa
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)app.firka.shared
+
com.apple.developer.ubiquity-kvstore-identifier
$(TeamIdentifierPrefix)app.firka.firkaa
diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift
index d59681ba..6079924c 100644
--- a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift
+++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift
@@ -375,7 +375,7 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
try TokenManager.shared.saveToken(
token,
- syncToICloud: false,
+ syncToSharedKeychain: false,
forceAccountSwitch: shouldForceAccountSwitch
)
print("[Watch] Token saved successfully")
diff --git a/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift
index 794b178f..0f9b02b9 100644
--- a/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift
+++ b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift
@@ -289,7 +289,7 @@ struct ReauthRequiredView: View {
try TokenManager.shared.saveToken(
token,
- syncToICloud: false,
+ syncToSharedKeychain: false,
forceAccountSwitch: shouldForceAccountSwitch
)
diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj
index d04c6649..b9c52916 100644
--- a/firka/ios/Runner.xcodeproj/project.pbxproj
+++ b/firka/ios/Runner.xcodeproj/project.pbxproj
@@ -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 = ""; };
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 = ""; };
4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
+ 4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychainManager.swift; sourceTree = ""; };
4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityMethodChannelManager.swift; sourceTree = ""; };
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 = ""; };
- 4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iCloudTokenManager.swift; sourceTree = ""; };
4F5824822F3548B800B92EA7 /* WatchToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchToken.swift; sourceTree = ""; };
4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; };
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 = "";
- };
- 4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {
- isa = PBXFileSystemSynchronizedRootGroup;
- exceptions = (
- 4F5966082F2F0EB100A3DB03 /* Exceptions for "FirkaWatchComplications" folder in "FirkaWatchComplicationsExtension" target */,
- );
- explicitFileTypes = {
- };
- explicitFolders = (
- );
- path = FirkaWatchComplications;
- sourceTree = "";
- };
- 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 = "";
- };
- 4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {
- isa = PBXFileSystemSynchronizedRootGroup;
- exceptions = (
- );
- explicitFileTypes = {
- };
- explicitFolders = (
- );
- path = "FirkaWatch Watch App";
- sourceTree = "";
- };
+ 4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LiveActivityWidget; sourceTree = ""; };
+ 4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FirkaWatchComplications; sourceTree = ""; };
+ 4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = ""; };
+ 4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "FirkaWatch Watch App"; sourceTree = ""; };
/* 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 = "";
@@ -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",
diff --git a/firka/ios/Runner/Info.plist b/firka/ios/Runner/Info.plist
index 338316ae..55b31580 100644
--- a/firka/ios/Runner/Info.plist
+++ b/firka/ios/Runner/Info.plist
@@ -46,7 +46,7 @@
CFBundleVersion
- 1068
+ 1101
LSRequiresIPhoneOS
NSAppTransportSecurity
diff --git a/firka/ios/Runner/Runner.entitlements b/firka/ios/Runner/Runner.entitlements
index 48392c50..60b320e4 100644
--- a/firka/ios/Runner/Runner.entitlements
+++ b/firka/ios/Runner/Runner.entitlements
@@ -10,6 +10,10 @@
group.app.firka.firkaa
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)app.firka.shared
+
com.apple.developer.ubiquity-kvstore-identifier
$(TeamIdentifierPrefix)app.firka.firkaa
diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift
index 85b600b7..2801aa85 100644
--- a/firka/ios/Runner/WatchSessionManager.swift
+++ b/firka/ios/Runner/WatchSessionManager.swift
@@ -111,12 +111,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
token.expiryDate > Date().addingTimeInterval(skewSeconds)
}
- private func fallbackTokenFromiCloud() -> [String: Any]? {
- guard let token = iCloudTokenManager.shared.loadToken() else {
+ private func fallbackTokenFromSharedKeychain() -> [String: Any]? {
+ guard let token = SharedKeychainManager.shared.loadToken() else {
return nil
}
guard isTokenUsable(token, skewSeconds: 0) else {
- print("[WatchSessionManager] iCloud fallback token is expired, skipping fallback")
+ print("[WatchSessionManager] Shared Keychain fallback token is expired, skipping fallback")
return nil
}
return tokenPayload(from: token)
@@ -297,17 +297,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))
}
@@ -345,11 +345,11 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
updatedAtMs: updatedAtMs
)
- iCloudTokenManager.shared.saveToken(token, deviceName: "iPhone")
+ SharedKeychainManager.shared.saveToken(token)
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)
}
@@ -366,7 +366,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
private func handleClearICloudToken(result: @escaping FlutterResult) {
- iCloudTokenManager.shared.deleteToken()
+ SharedKeychainManager.shared.deleteToken()
+
+ SharedKeychainManager.shared.clearKVStore()
result(nil)
}
@@ -441,7 +443,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 {
@@ -457,7 +459,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
if error == "needsReauth" {
print("[WatchSessionManager] Flutter reported needsReauth, not using iCloud fallback")
replyHandler(["error": error])
- } else if let fallbackToken = self.fallbackTokenFromiCloud() {
+ } else if let fallbackToken = self.fallbackTokenFromSharedKeychain() {
print("[WatchSessionManager] Flutter returned error (\(error)), falling back to iCloud token")
replyHandler(["auth": fallbackToken])
} else {
@@ -474,7 +476,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
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 {
diff --git a/firka/ios/Shared/API/SharedKeychainManager.swift b/firka/ios/Shared/API/SharedKeychainManager.swift
new file mode 100644
index 00000000..b34ba92c
--- /dev/null
+++ b/firka/ios/Shared/API/SharedKeychainManager.swift
@@ -0,0 +1,281 @@
+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 var changeObserver: ((WatchToken) -> Void)?
+
+ private init() {}
+
+ 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: - Observer (for compatibility with old iCloudTokenManager interface)
+ func observeChanges(_ observer: @escaping (WatchToken) -> Void) {
+ self.changeObserver = observer
+ }
+
+ func notifyObservers(with token: WatchToken) {
+ changeObserver?(token)
+ }
+
+ // 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")
+ }
+}
diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift
index 9a5b6b29..c18cc036 100644
--- a/firka/ios/Shared/API/TokenManager.swift
+++ b/firka/ios/Shared/API/TokenManager.swift
@@ -126,10 +126,10 @@ class TokenManager {
}
}
- private func probeICloudTokenWithTimeout() async -> WatchToken? {
+ private func probeSharedKeychainTokenWithTimeout() async -> WatchToken? {
await withTaskGroup(of: WatchToken?.self) { group in
group.addTask {
- iCloudTokenManager.shared.loadToken()
+ SharedKeychainManager.shared.loadToken()
}
group.addTask { [iCloudProbeTimeoutNs] in
try? await Task.sleep(nanoseconds: iCloudProbeTimeoutNs)
@@ -143,30 +143,32 @@ class TokenManager {
}
private init() {
- iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in
+ runKVStoreMigrationIfNeeded()
+
+ SharedKeychainManager.shared.observeChanges { [weak self] sharedToken in
guard let self = self else { return }
let preferredStudentIdNorm = self.getActiveStudentIdNorm()
- let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60)
+ let isValidToken = sharedToken.expiryDate > Date().addingTimeInterval(60)
let preferredLocalToken = self.localTokenFromKeychainAndFile(
preferredStudentIdNorm: preferredStudentIdNorm
)
if let preferredStudentIdNorm,
- iCloudToken.studentIdNorm != preferredStudentIdNorm,
+ sharedToken.studentIdNorm != preferredStudentIdNorm,
preferredLocalToken != nil {
- print("[TokenManager] Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
+ print("[TokenManager] Ignoring shared Keychain token for inactive account (\(sharedToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
return
}
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 sharedToken.isNewer(than: localToken) {
+ print("[TokenManager] Shared Keychain token is fresher, updating local cache")
+ try? self.saveTokenToKeychain(sharedToken)
+ try? self.saveTokenToFile(sharedToken)
+ self.setActiveStudentIdNorm(sharedToken.studentIdNorm)
#if os(watchOS)
DataStore.shared.checkTokenState()
@@ -178,13 +180,13 @@ class TokenManager {
}
#endif
} else {
- print("[TokenManager] Local token is fresher or equal, ignoring iCloud update")
+ print("[TokenManager] Local token is fresher or equal, ignoring shared Keychain update")
}
} else {
- print("[TokenManager] No local token, using iCloud token")
- try? self.saveTokenToKeychain(iCloudToken)
- try? self.saveTokenToFile(iCloudToken)
- self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
+ print("[TokenManager] No local token, using shared Keychain token")
+ try? self.saveTokenToKeychain(sharedToken)
+ try? self.saveTokenToFile(sharedToken)
+ self.setActiveStudentIdNorm(sharedToken.studentIdNorm)
#if os(watchOS)
DataStore.shared.checkTokenState()
@@ -199,6 +201,32 @@ class TokenManager {
}
}
+ 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")
@@ -221,12 +249,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")) }
@@ -299,7 +327,7 @@ class TokenManager {
func deleteToken() {
print("[TokenManager] Deleting token from all storage locations")
deleteTokenFromKeychain()
- iCloudTokenManager.shared.deleteToken()
+ SharedKeychainManager.shared.deleteToken()
UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey)
guard let filePath = getTokenFilePath() else { return }
@@ -309,7 +337,7 @@ class TokenManager {
// MARK: - Save Token
func saveToken(
_ token: WatchToken,
- syncToICloud: Bool = false,
+ syncToSharedKeychain: Bool = false,
forceAccountSwitch: Bool = false
) throws {
if let currentToken = loadToken() {
@@ -326,8 +354,8 @@ class TokenManager {
try saveTokenToKeychain(token)
- if syncToICloud {
- iCloudTokenManager.shared.saveToken(token, deviceName: deviceName)
+ if syncToSharedKeychain {
+ SharedKeychainManager.shared.saveToken(token, forceAccountSwitch: forceAccountSwitch)
}
guard let filePath = getTokenFilePath() else {
@@ -515,26 +543,26 @@ class TokenManager {
print("[TokenManager] Starting central token recovery...")
- if let iCloudToken = await probeICloudTokenWithTimeout() {
+ if let sharedToken = await probeSharedKeychainTokenWithTimeout() {
let now = Date()
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
- iCloudToken.studentIdNorm != preferredStudentIdNorm,
+ sharedToken.studentIdNorm != preferredStudentIdNorm,
localTokenFromKeychainAndFile(preferredStudentIdNorm: preferredStudentIdNorm) != nil {
- print("[TokenManager] iCloud probe token belongs to inactive account, skipping direct apply")
- } else if iCloudToken.expiryDate > now.addingTimeInterval(60) {
- print("[TokenManager] iCloud probe found valid token, applying without recovery")
+ 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(iCloudToken, syncToICloud: false)
+ try saveToken(sharedToken, syncToSharedKeychain: false)
clearLastRecoveryFailure()
- return iCloudToken
+ return sharedToken
} catch {
- print("[TokenManager] Failed to apply iCloud probe token: \(error)")
+ print("[TokenManager] Failed to apply shared Keychain probe token: \(error)")
}
} else {
- print("[TokenManager] iCloud probe token exists but access is expired, continuing with refresh path")
+ print("[TokenManager] Shared Keychain probe token exists but access is expired, continuing with refresh path")
}
} else {
- print("[TokenManager] iCloud probe timed out or no token available, continuing with refresh path")
+ print("[TokenManager] Shared Keychain probe timed out or no token available, continuing with refresh path")
}
print("[TokenManager] Step 1: Trying local token refresh...")
@@ -567,7 +595,7 @@ 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 {
@@ -591,48 +619,48 @@ class TokenManager {
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)
+ 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 iCloudToken
+ 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 {
@@ -643,9 +671,9 @@ class TokenManager {
}
}
} 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
}
}
}
@@ -800,7 +828,7 @@ class TokenManager {
updatedAtMs: nowMs
)
- try saveToken(newToken, syncToICloud: true)
+ try saveToken(newToken, syncToSharedKeychain: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
@@ -834,7 +862,7 @@ class TokenManager {
updatedAtMs: nowMs
)
- try saveToken(newToken, syncToICloud: true)
+ try saveToken(newToken, syncToSharedKeychain: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
diff --git a/firka/ios/Shared/API/iCloudTokenManager.swift b/firka/ios/Shared/API/iCloudTokenManager.swift
deleted file mode 100644
index f31c9fdd..00000000
--- a/firka/ios/Shared/API/iCloudTokenManager.swift
+++ /dev/null
@@ -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)
- }
-}