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