From bb2fa0f3385edb68df8e603e1ced79f1a6cd6d3b Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:30:11 +0000 Subject: [PATCH 1/5] feat: add the Learning Sites screen (list, switch, logout, branded sign-in) - add Learning Sites list: switch sites, per-site logout, add a new site - query/clear a specific instance's session without switching to it - branded per-instance Sign In (logo, header image) - route to Learning Sites instead of Sign In when there's more than one instance - fix: Settings log out now goes through the session manager, not downloadManager directly --- .../Generated/AppDatesMocks.generated.swift | 8 + .../Authorization.xcodeproj/project.pbxproj | 28 +++ .../LearningSites/LearningSitesView.swift | 188 ++++++++++++++++++ .../LearningSitesViewModel.swift | 96 +++++++++ .../Presentation/Login/SignInView.swift | 28 ++- .../Presentation/Login/SignInViewModel.swift | 7 + .../Authorization/SwiftGen/Strings.swift | 22 ++ .../en.lproj/Localizable.strings | 10 + .../AuthorizationMocks.generated.swift | 8 + .../LearningSitesViewModelTests.swift | 121 +++++++++++ .../Login/SignInViewModelTests.swift | 11 + Core/Core.xcodeproj/project.pbxproj | 4 + .../InstanceSessionManager.swift | 42 ++++ Core/Core/Data/CoreStorage.swift | 11 + Core/Core/View/Base/InstanceThemedImage.swift | 40 ++++ .../Generated/CoreMocks.generated.swift | 8 + .../Generated/CourseMocks.generated.swift | 8 + .../Generated/DashboardMocks.generated.swift | 8 + .../Generated/DiscoveryMocks.generated.swift | 8 + .../Generated/DiscussionMocks.generated.swift | 8 + .../Generated/DownloadsMocks.generated.swift | 8 + OpenEdX/DI/ScreenAssembly.swift | 13 +- OpenEdX/Data/AppStorage.swift | 13 ++ OpenEdX/RouteController.swift | 18 ++ OpenEdX/Router.swift | 22 ++ .../Profile/Presentation/ProfileRouter.swift | 8 +- .../Presentation/Settings/SettingsView.swift | 29 ++- .../Settings/SettingsViewModel.swift | 8 +- .../Settings/VideoQualityView.swift | 2 +- .../Settings/VideoSettingsView.swift | 2 +- Profile/Profile/SwiftGen/Strings.swift | 2 + Profile/Profile/en.lproj/Localizable.strings | 1 + .../Generated/ProfileMocks.generated.swift | 27 +++ .../Settings/SettingsViewModelTests.swift | 25 +-- 34 files changed, 809 insertions(+), 33 deletions(-) create mode 100644 Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift create mode 100644 Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift create mode 100644 Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift create mode 100644 Core/Core/View/Base/InstanceThemedImage.swift diff --git a/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift b/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift index a8f818620..353436753 100644 --- a/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift +++ b/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift @@ -341,6 +341,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index cc7f6c2e9..6c2ac11bd 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071009C628D1DA4F00344290 /* SignInViewModel.swift */; }; 07169458296D913400E3DED6 /* Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE3B28D0A319006D8A5D /* Authorization.framework */; platformFilter = ios; }; 07169464296D96DD00E3DED6 /* SignInViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07169463296D96DD00E3DED6 /* SignInViewModelTests.swift */; }; + CC17B772EB764690BF49B5B4 /* LearningSitesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20675D1281E4AFDB49DB428 /* LearningSitesViewModelTests.swift */; }; 0770DE4728D0A3DA006D8A5D /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE4628D0A3DA006D8A5D /* Core.framework */; }; 0770DE4E28D0A677006D8A5D /* SignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE4D28D0A677006D8A5D /* SignInView.swift */; }; 0770DE6828D0BF03006D8A5D /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6728D0BF03006D8A5D /* swiftgen.yml */; }; @@ -45,6 +46,8 @@ DE843D6BB1B9DDA398494890 /* Pods_App_Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */; }; E03261642AE64676002CA7EB /* StartupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261632AE64676002CA7EB /* StartupViewModel.swift */; }; E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261652AE64AF4002CA7EB /* StartupView.swift */; }; + D3823C1D148543EBAF6E72D5 /* LearningSitesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1A59CA22C043B5B0B43646 /* LearningSitesViewModel.swift */; }; + 4B59BF99F958493F94F19119 /* LearningSitesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9BDA13C3C4465B82EBFFA4 /* LearningSitesView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -83,6 +86,7 @@ 071009C628D1DA4F00344290 /* SignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInViewModel.swift; sourceTree = ""; }; 07169454296D913300E3DED6 /* AuthorizationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthorizationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 07169463296D96DD00E3DED6 /* SignInViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInViewModelTests.swift; sourceTree = ""; }; + D20675D1281E4AFDB49DB428 /* LearningSitesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearningSitesViewModelTests.swift; sourceTree = ""; }; 0770DE3B28D0A319006D8A5D /* Authorization.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Authorization.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE4628D0A3DA006D8A5D /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE4D28D0A677006D8A5D /* SignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInView.swift; sourceTree = ""; }; @@ -119,6 +123,8 @@ CEB25A002CC13A36007FC792 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; E03261632AE64676002CA7EB /* StartupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupViewModel.swift; sourceTree = ""; }; E03261652AE64AF4002CA7EB /* StartupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupView.swift; sourceTree = ""; }; + 3C1A59CA22C043B5B0B43646 /* LearningSitesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearningSitesViewModel.swift; sourceTree = ""; }; + 9A9BDA13C3C4465B82EBFFA4 /* LearningSitesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearningSitesView.swift; sourceTree = ""; }; E78971D8E6ED2116BBF9FD66 /* Pods-App-Authorization.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.release.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.release.xcconfig"; sourceTree = ""; }; F52826C68AEA1CF4769389EA /* Pods-App-Authorization.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releasestage.xcconfig"; sourceTree = ""; }; F5802BBA113276950ABCD9B3 /* Pods-App-Authorization.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releaseprod.xcconfig"; sourceTree = ""; }; @@ -190,6 +196,7 @@ 99C165492C0C4EF000DC384D /* SSO */, BA8B3A302AD5485100D25EF5 /* SocialAuth */, E03261622AE6464A002CA7EB /* Startup */, + 3B80FE1C87E04870A6AF2DA5 /* LearningSites */, 020C31BD290AADA700D6DEA2 /* Base */, 071009C528D1D9FA00344290 /* Login */, 07169462296D93E000E3DED6 /* Registration */, @@ -223,6 +230,7 @@ children = ( 022D0480297442B200E0059B /* Register */, 07169484296EC3D700E3DED6 /* Login */, + F316F4DD3C0A4F01BB8EF3AB /* LearningSites */, ); path = Presentation; sourceTree = ""; @@ -236,6 +244,14 @@ path = Login; sourceTree = ""; }; + F316F4DD3C0A4F01BB8EF3AB /* LearningSites */ = { + isa = PBXGroup; + children = ( + D20675D1281E4AFDB49DB428 /* LearningSitesViewModelTests.swift */, + ); + path = LearningSites; + sourceTree = ""; + }; 0770DE3128D0A318006D8A5D = { isa = PBXGroup; children = ( @@ -368,6 +384,15 @@ path = Startup; sourceTree = ""; }; + 3B80FE1C87E04870A6AF2DA5 /* LearningSites */ = { + isa = PBXGroup; + children = ( + 3C1A59CA22C043B5B0B43646 /* LearningSitesViewModel.swift */, + 9A9BDA13C3C4465B82EBFFA4 /* LearningSitesView.swift */, + ); + path = LearningSites; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -556,6 +581,7 @@ files = ( 022D0482297442BA00E0059B /* SignUpViewModelTests.swift in Sources */, 07169464296D96DD00E3DED6 /* SignInViewModelTests.swift in Sources */, + CC17B772EB764690BF49B5B4 /* LearningSitesViewModelTests.swift in Sources */, A5B468112F29C845002A4ECA /* AuthorizationMocks.generated.swift in Sources */, 02E0618429DC2373006E9024 /* ResetPasswordViewModelTests.swift in Sources */, ); @@ -580,6 +606,8 @@ 02F3BFE5292533720051930C /* AuthorizationRouter.swift in Sources */, 99C1654F2C0C4F5900DC384D /* SSOWebView.swift in Sources */, E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */, + D3823C1D148543EBAF6E72D5 /* LearningSitesViewModel.swift in Sources */, + 4B59BF99F958493F94F19119 /* LearningSitesView.swift in Sources */, 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */, BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */, 99C1654D2C0C4F2F00DC384D /* SSOHelper.swift in Sources */, diff --git a/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift b/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift new file mode 100644 index 000000000..0a8dde9fc --- /dev/null +++ b/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift @@ -0,0 +1,188 @@ +// +// LearningSitesView.swift +// Authorization +// +// Created by Rawan Matar on 22/09/2026. +// + +import SwiftUI +import Core +import Theme + +public struct LearningSitesView: View { + + @Bindable private var viewModel: LearningSitesViewModel + @Environment(\.isHorizontal) private var isHorizontal + + public init(viewModel: LearningSitesViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") + + VStack(alignment: .center) { + ZStack { + HStack { + Text(AuthLocalization.LearningSites.title) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("learning_sites_title_text") + } + if viewModel.isSwitching { + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { viewModel.router.back() } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) + } + } + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if !viewModel.currentLearningSites.isEmpty { + sitesSection( + title: AuthLocalization.LearningSites.currentSite, + sites: viewModel.currentLearningSites, + showsLogOut: true + ) + } + + sitesSection( + title: viewModel.currentLearningSites.isEmpty + ? AuthLocalization.LearningSites.title + : AuthLocalization.LearningSites.moreSites, + sites: viewModel.addableSites, + showsLogOut: false, + searchField: true + ) + } + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 24) + } + .roundedBackground(Theme.Colors.background) + } + } + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + } + + @ViewBuilder + private func sitesSection( + title: String, + sites: [Instance], + showsLogOut: Bool, + searchField: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + + if searchField { + searchBar + } + + if sites.isEmpty && searchField { + Text(AuthLocalization.LearningSites.emptySearch) + .font(Theme.Fonts.bodySmall) + .foregroundColor(Theme.Colors.textSecondary) + } else { + ForEach(sites) { instance in + siteRow(instance, showsLogOut: showsLogOut) + Divider() + } + } + } + } + + private var searchBar: some View { + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .padding(.leading, 16) + .foregroundColor(Theme.Colors.textInputTextColor) + TextField("", text: $viewModel.searchText) + .autocapitalization(.none) + .autocorrectionDisabled() + .frame(minHeight: 50) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textInputTextColor) + .accessibilityIdentifier("learning_sites_search_field") + } + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + .background( + Theme.InputFieldBackground( + placeHolder: AuthLocalization.LearningSites.searchPlaceholder, + text: viewModel.searchText, + padding: 48 + ) + ) + } + + // A `Button` can't nest inside another `Button`'s label -- the outer one swallows every + // tap. The row itself is tappable via `.onTapGesture`; the X stays a real, separate + // `Button` so it keeps receiving its own taps. + private func siteRow(_ instance: Instance, showsLogOut: Bool) -> some View { + HStack(spacing: 12) { + InstanceThemedImage( + source: instance.logoURLString, + allowsBundledAsset: true, + fallback: ThemeAssets.appLogo.swiftUIImage + ) + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + + Text(instance.name) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + + Spacer() + + if showsLogOut { + Button(action: { presentLogOutConfirm(for: instance) }, label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Theme.Colors.textSecondary) + }) + .accessibilityIdentifier("log_out_site_button") + } + } + .contentShape(Rectangle()) + .onTapGesture { + Task { await viewModel.select(instance) } + } + .accessibilityIdentifier("select_site_button") + } + + private func presentLogOutConfirm(for instance: Instance) { + viewModel.router.presentAlert( + alertTitle: AuthLocalization.LearningSites.logOutTitle, + alertMessage: AuthLocalization.LearningSites.logOutMessage(instance.name), + positiveAction: AuthLocalization.LearningSites.logOutAction, + onCloseTapped: { + viewModel.router.dismiss(animated: true) + }, + firstButtonTapped: { + viewModel.router.dismiss(animated: true) + Task { await viewModel.logOut(instance) } + }, + type: .logOut + ) + } +} diff --git a/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift b/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift new file mode 100644 index 000000000..9e8316b39 --- /dev/null +++ b/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift @@ -0,0 +1,96 @@ +// +// LearningSitesViewModel.swift +// Authorization +// +// Created by Rawan Matar on 22/09/2026. +// + +import Foundation +import Core + +@MainActor +@Observable public class LearningSitesViewModel { + + private let instanceStore: InstanceStore + private let storage: CoreStorage + private let sessionManager: InstanceSessionManagerProtocol + let router: AuthorizationRouter + + /// true when pushed from Settings (shows a back button, manages existing sites); false + /// at first launch, where this is the root screen with nothing logged into yet. + let isSwitching: Bool + + /// Catalog instances the user is already logged into. + private(set) var currentLearningSites: [Instance] = [] + + /// Catalog instances not yet logged into, filtered by `searchText`. + private(set) var addableSites: [Instance] = [] + + var searchText: String = "" { + didSet { updateAddableSites() } + } + + /// The full catalog, alphabetical -- refreshed on init and after a logout, since that's + /// the only action that can change which instances count as "learning sites". + private var catalog: [Instance] = [] + + public init( + instanceStore: InstanceStore, + storage: CoreStorage, + sessionManager: InstanceSessionManagerProtocol, + router: AuthorizationRouter, + isSwitching: Bool + ) { + self.instanceStore = instanceStore + self.storage = storage + self.sessionManager = sessionManager + self.router = router + self.isSwitching = isSwitching + refresh() + } + + private func hasSession(_ instance: Instance) -> Bool { + storage.hasSession(forInstanceKey: instance.key) + } + + private func refresh() { + catalog = instanceStore.instancesConfig.instances.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + currentLearningSites = catalog.filter(hasSession) + updateAddableSites() + } + + private func updateAddableSites() { + let notLoggedIn = catalog.filter { !hasSession($0) } + guard !searchText.isEmpty else { + addableSites = notLoggedIn + return + } + addableSites = notLoggedIn.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + || $0.instanceName.localizedCaseInsensitiveContains(searchText) + } + } + + /// Selecting an already-signed-in site switches straight to Main; otherwise switches + /// the active instance and routes to that site's own Sign In screen. + func select(_ instance: Instance) async { + let alreadySignedIn = hasSession(instance) + await sessionManager.switchActiveInstance(to: instance) + if alreadySignedIn { + router.showMainOrWhatsNewScreen(sourceScreen: .default, postLoginData: nil) + } else { + router.showLoginScreen(sourceScreen: .default) + } + } + + /// Logs out of `instance`. Full teardown if it's the active instance, otherwise just + /// clears that instance's stored session -- see `InstanceSessionManagerProtocol.logout`. + /// The confirm dialog is presented by the view directly (`router.presentAlert`), same + /// pattern as Settings' own Log Out button -- no pending-state needed here. + func logOut(_ instance: Instance) async { + await sessionManager.logout(instance) + refresh() + } +} diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index aeb19f3e4..bdd92a009 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -27,10 +27,12 @@ public struct SignInView: View { public var body: some View { ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) - .accessibilityIdentifier("auth_bg_image") + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) + .edgesIgnoringSafeArea(.top) + .accessibilityIdentifier("auth_bg_image") }.frame(maxWidth: .infinity, maxHeight: 200) if viewModel.config.features.startupScreenEnabled { VStack { @@ -49,13 +51,16 @@ public struct SignInView: View { } VStack(alignment: .center) { - ThemeAssets.appLogo.swiftUIImage - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 189, maxHeight: 89) - .padding(.top, isHorizontal ? 20 : 40) - .padding(.bottom, isHorizontal ? 10 : 40) - .accessibilityIdentifier("logo_image") + InstanceThemedImage( + source: viewModel.currentInstance?.logoURLString, + allowsBundledAsset: true, + fallback: ThemeAssets.appLogo.swiftUIImage + ) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 189, maxHeight: 89) + .padding(.top, isHorizontal ? 20 : 40) + .padding(.bottom, isHorizontal ? 10 : 40) + .accessibilityIdentifier("logo_image") GeometryReader { proxy in ScrollView { @@ -332,6 +337,7 @@ struct SignInView_Previews: PreviewProvider { analytics: AuthorizationAnalyticsMock(), validator: Validator(), storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 659d1b2c8..ebc8f85dc 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -44,6 +44,11 @@ import MSAL private let analytics: AuthorizationAnalytics private let validator: Validator let storage: CoreStorage + private let instanceProvider: InstanceProvider + + /// The instance being signed into, for per-instance branding. `nil` on a single-instance + /// deployment, where the static default branding applies. + var currentInstance: Instance? { instanceProvider.currentInstance } public init( interactor: AuthInteractorProtocol, @@ -52,6 +57,7 @@ import MSAL analytics: AuthorizationAnalytics, validator: Validator, storage: CoreStorage, + instanceProvider: InstanceProvider, sourceScreen: LogistrationSourceScreen ) { self.interactor = interactor @@ -60,6 +66,7 @@ import MSAL self.analytics = analytics self.validator = validator self.storage = storage + self.instanceProvider = instanceProvider self.sourceScreen = sourceScreen } diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index 99fddd18f..dd3218c7e 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -118,6 +118,28 @@ public enum AuthLocalization { /// Start public static let title = AuthLocalization.tr("Localizable", "STARTUP.TITLE", fallback: "Start") } + public enum LearningSites { + /// Learning Sites + public static let title = AuthLocalization.tr("Localizable", "LEARNING_SITES.TITLE", fallback: "Learning Sites") + /// Current Learning Site + public static let currentSite = AuthLocalization.tr("Localizable", "LEARNING_SITES.CURRENT_SITE", fallback: "Current Learning Site") + /// More Learning Sites + public static let moreSites = AuthLocalization.tr("Localizable", "LEARNING_SITES.MORE_SITES", fallback: "More Learning Sites") + /// Add a Learning Site + public static let addSite = AuthLocalization.tr("Localizable", "LEARNING_SITES.ADD_SITE", fallback: "Add a Learning Site") + /// Search for your school + public static let searchPlaceholder = AuthLocalization.tr("Localizable", "LEARNING_SITES.SEARCH_PLACEHOLDER", fallback: "Search for your school") + /// No schools match your search + public static let emptySearch = AuthLocalization.tr("Localizable", "LEARNING_SITES.EMPTY_SEARCH", fallback: "No schools match your search") + /// Log Out + public static let logOutTitle = AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_TITLE", fallback: "Log Out") + /// Are you sure you want to log out of %@? + public static func logOutMessage(_ p1: Any) -> String { + return AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_MESSAGE", String(describing: p1), fallback: "Are you sure you want to log out of %@?") + } + /// Log Out + public static let logOutAction = AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_ACTION", fallback: "Log Out") + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index 380bf41e6..f69fe61dc 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -55,3 +55,13 @@ accordance with the [Privacy Policy.](%@)"; "STARTUP.SEARCH_PLACEHOLDER" = "Search our 3000+ courses"; "STARTUP.EXPLORE_ALL_COURSES" = "Explore all courses"; "STARTUP.TITLE" = "Start"; + +"LEARNING_SITES.TITLE" = "Learning Sites"; +"LEARNING_SITES.CURRENT_SITE" = "Current Learning Site"; +"LEARNING_SITES.MORE_SITES" = "More Learning Sites"; +"LEARNING_SITES.ADD_SITE" = "Add a Learning Site"; +"LEARNING_SITES.SEARCH_PLACEHOLDER" = "Search for your school"; +"LEARNING_SITES.EMPTY_SEARCH" = "No schools match your search"; +"LEARNING_SITES.LOG_OUT_TITLE" = "Log Out"; +"LEARNING_SITES.LOG_OUT_MESSAGE" = "Are you sure you want to log out of %@?"; +"LEARNING_SITES.LOG_OUT_ACTION" = "Log Out"; diff --git a/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift b/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift index 3a8c883b7..17e6f6964 100644 --- a/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift +++ b/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift @@ -341,6 +341,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift new file mode 100644 index 000000000..c2c8c2217 --- /dev/null +++ b/Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift @@ -0,0 +1,121 @@ +// +// LearningSitesViewModelTests.swift +// AuthorizationTests +// +// Created by Rawan Matar on 22/09/2026. +// + +import XCTest +@testable import Core +@testable import Authorization + +@MainActor +final class LearningSitesViewModelTests: XCTestCase { + + private func freshUserDefaults(_ name: String) -> UserDefaults { + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + private func makeStore(_ name: String) -> InstanceStore { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "NAME": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], + ["KEY": "beta", "NAME": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] + ]) + return InstanceStore(userDefaults: freshUserDefaults(name), instancesConfig: { catalog }) + } + + func test_currentLearningSites_reflectsHasSession() { + let storage = CoreStorageMock() + storage.sessionInstanceKeys = ["acme"] + let viewModel = LearningSitesViewModel( + instanceStore: makeStore(#function), + storage: storage, + sessionManager: InstanceSessionManagerProtocolMock(), + router: AuthorizationRouterMock(), + isSwitching: true + ) + + XCTAssertEqual(viewModel.currentLearningSites.map(\.key), ["acme"]) + XCTAssertEqual(viewModel.addableSites.map(\.key), ["beta"]) + } + + func test_searchText_filtersAddableSitesByName() { + let viewModel = LearningSitesViewModel( + instanceStore: makeStore(#function), + storage: CoreStorageMock(), + sessionManager: InstanceSessionManagerProtocolMock(), + router: AuthorizationRouterMock(), + isSwitching: false + ) + + viewModel.searchText = "bet" + + XCTAssertEqual(viewModel.addableSites.map(\.key), ["beta"]) + } + + func test_select_withoutSession_switchesThenShowsLoginScreen() async { + let router = AuthorizationRouterMock() + let sessionManager = InstanceSessionManagerProtocolMock() + let store = makeStore(#function) + let viewModel = LearningSitesViewModel( + instanceStore: store, + storage: CoreStorageMock(), + sessionManager: sessionManager, + router: router, + isSwitching: false + ) + + await viewModel.select(store.instancesConfig.instances[0]) + + XCTAssertEqual(sessionManager.switchActiveInstanceCallCount, 1) + XCTAssertEqual(router.showLoginScreenCallCount, 1) + XCTAssertEqual(router.showMainOrWhatsNewScreenCallCount, 0) + } + + func test_select_withExistingSession_switchesThenShowsMain() async { + let router = AuthorizationRouterMock() + let sessionManager = InstanceSessionManagerProtocolMock() + let storage = CoreStorageMock() + storage.sessionInstanceKeys = ["acme"] + let store = makeStore(#function) + let viewModel = LearningSitesViewModel( + instanceStore: store, + storage: storage, + sessionManager: sessionManager, + router: router, + isSwitching: true + ) + + await viewModel.select(store.instance(withKey: "acme")!) + + XCTAssertEqual(sessionManager.switchActiveInstanceCallCount, 1) + XCTAssertEqual(router.showMainOrWhatsNewScreenCallCount, 1) + XCTAssertEqual(router.showLoginScreenCallCount, 0) + } + + func test_logOut_callsSessionManagerAndRefreshesTheList() async { + let sessionManager = InstanceSessionManagerProtocolMock() + let storage = CoreStorageMock() + storage.sessionInstanceKeys = ["acme"] + let store = makeStore(#function) + let viewModel = LearningSitesViewModel( + instanceStore: store, + storage: storage, + sessionManager: sessionManager, + router: AuthorizationRouterMock(), + isSwitching: true + ) + let acme = store.instance(withKey: "acme")! + XCTAssertEqual(viewModel.currentLearningSites.map(\.key), ["acme"]) + + // The mock session manager doesn't touch `storage` itself -- mirror what the real + // InstanceSessionManager does so `refresh()` sees the logout reflected. + sessionManager.logoutHandler = { _ in storage.sessionInstanceKeys.remove("acme") } + await viewModel.logOut(acme) + + XCTAssertEqual(sessionManager.logoutCallCount, 1) + XCTAssertTrue(viewModel.currentLearningSites.isEmpty) + } +} diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index 324a5d748..5157342ca 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -35,6 +35,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) @@ -59,6 +60,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) await viewModel.login(username: "edxUser@edx.com", password: "") @@ -82,6 +84,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) let user = User(id: 1, username: "username", email: "edxUser@edx.com", name: "Name", userAvatar: "") @@ -110,6 +113,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) let user = User(id: 1, username: "username", email: "edxUser@edx.com", name: "Name", userAvatar: "") @@ -137,6 +141,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) @@ -169,6 +174,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) @@ -205,6 +211,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) @@ -235,6 +242,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) @@ -261,6 +269,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) @@ -287,6 +296,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) @@ -315,6 +325,7 @@ final class SignInViewModelTests: XCTestCase { analytics: analytics, validator: validator, storage: CoreStorageMock(), + instanceProvider: InstanceProviderMock(), sourceScreen: .default ) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 5df2e7607..a6c37beae 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -146,6 +146,7 @@ A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */; }; BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */; }; + B2AECBAAC7724AB79606BE34 /* InstanceThemedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57672758BE8D441C9F585B61 /* InstanceThemedImage.swift */; }; BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; BA593F1E2AF8E4A0009ADB51 /* FrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */; }; BA8FA6682AD59A5700EA029A /* SocialAuthButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */; }; @@ -379,6 +380,7 @@ B2556B4A2D4F84F402A7A7D9 /* Pods-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.releaseprod.xcconfig"; sourceTree = ""; }; BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityView.swift; sourceTree = ""; }; BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxView.swift; sourceTree = ""; }; + 57672758BE8D441C9F585B61 /* InstanceThemedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceThemedImage.swift; sourceTree = ""; }; BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = ""; }; BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameReader.swift; sourceTree = ""; }; BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthButton.swift; sourceTree = ""; }; @@ -788,6 +790,7 @@ 027BD3C42909707700392132 /* Shake.swift */, 023A1135291432B200D0D354 /* RegistrationTextField.swift */, BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */, + 57672758BE8D441C9F585B61 /* InstanceThemedImage.swift */, 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */, BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, @@ -1275,6 +1278,7 @@ 064987992B4D69FF0071642A /* WebViewScriptInjectionProtocol.swift in Sources */, 070019AE28F701B200D5FC78 /* Certificate.swift in Sources */, BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */, + B2AECBAAC7724AB79606BE34 /* InstanceThemedImage.swift in Sources */, 076F297F2A1F80C800967E7D /* Pagination.swift in Sources */, 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */, CEC873BE2D75D55600A2626C /* CourseStructureManagerProtocol.swift in Sources */, diff --git a/Core/Core/Configuration/InstanceSessionManager.swift b/Core/Core/Configuration/InstanceSessionManager.swift index 73908ecd4..a225c9f9d 100644 --- a/Core/Core/Configuration/InstanceSessionManager.swift +++ b/Core/Core/Configuration/InstanceSessionManager.swift @@ -17,6 +17,11 @@ public protocol InstanceSessionManagerProtocol: Sendable { /// storage, then clears the selection. Leaves CoreData rows and downloaded files /// in place so a later re-login resumes fast. func logoutCurrentInstance() async + + /// Logs out of `instance`'s session. Routes to `logoutCurrentInstance()` (full teardown) + /// when it's the active instance; otherwise just clears that instance's stored tokens, + /// leaving the active session untouched. + func logout(_ instance: Instance) async } public final class InstanceSessionManager: InstanceSessionManagerProtocol { @@ -62,4 +67,41 @@ public final class InstanceSessionManager: InstanceSessionManagerProtocol { // CoreData rows and downloaded files are left in place on purpose -- // see CoreDataHandlerProtocol.clear(instanceKey:) for an explicit wipe. } + + public func logout(_ instance: Instance) async { + if instanceStore.currentInstance?.key == instance.key { + await logoutCurrentInstance() + } else { + storage.clearSession(forInstanceKey: instance.key) + } + } +} + +#if DEBUG +public final class InstanceSessionManagerProtocolMock: InstanceSessionManagerProtocol, @unchecked Sendable { + public private(set) var switchActiveInstanceCallCount = 0 + public private(set) var logoutCurrentInstanceCallCount = 0 + public private(set) var logoutCallCount = 0 + public private(set) var lastLoggedOutInstance: Instance? + + /// Optional side effect run by `logout(_:)`, for tests that need to simulate the real + /// implementation's storage mutation (e.g. clearing a `CoreStorageMock` session key). + public var logoutHandler: ((Instance) -> Void)? + + public init() {} + + public func switchActiveInstance(to instance: Instance) async { + switchActiveInstanceCallCount += 1 + } + + public func logoutCurrentInstance() async { + logoutCurrentInstanceCallCount += 1 + } + + public func logout(_ instance: Instance) async { + logoutCallCount += 1 + lastLoggedOutInstance = instance + logoutHandler?(instance) + } } +#endif diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 32b511f7b..156029981 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -25,6 +25,8 @@ public protocol CoreStorage: Sendable { var latestAvailableAppVersion: String? {get set} var updateAppRequired: Bool {get set} func clear() + func hasSession(forInstanceKey key: String) -> Bool + func clearSession(forInstanceKey key: String) } #if DEBUG @@ -45,6 +47,15 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public var latestAvailableAppVersion: String? public var updateAppRequired: Bool = false public func clear() {} + + /// Namespace -> hasSession override, for tests. `hasSession(forInstanceKey:)` looks here. + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } public init() {} } diff --git a/Core/Core/View/Base/InstanceThemedImage.swift b/Core/Core/View/Base/InstanceThemedImage.swift new file mode 100644 index 000000000..a68524834 --- /dev/null +++ b/Core/Core/View/Base/InstanceThemedImage.swift @@ -0,0 +1,40 @@ +// +// InstanceThemedImage.swift +// Core +// +// Created by Rawan Matar on 22/09/2026. +// + +import SwiftUI + +/// Resolves an `Instance`-provided image source (`logoURLString`/`headerBackgroundURLString`) +/// to a displayable image: a remote URL loads via `AsyncImage`; a bundled asset name (only +/// `logoURLString` allows this) resolves via `UIImage(named:)`; anything else -- `nil`, an +/// unparsable URL, or a missing asset -- falls back to `fallback`. First use of `AsyncImage` +/// in this codebase. Callers chain their own sizing/`aspectRatio` modifiers, same as `Image`. +public struct InstanceThemedImage: View { + private let source: String? + private let allowsBundledAsset: Bool + private let fallback: Image + + public init(source: String?, allowsBundledAsset: Bool = false, fallback: Image) { + self.source = source + self.allowsBundledAsset = allowsBundledAsset + self.fallback = fallback + } + + public var body: some View { + if let source, let url = URL(string: source), let scheme = url.scheme, + scheme.hasPrefix("http") { + AsyncImage(url: url) { image in + image.resizable() + } placeholder: { + fallback.resizable() + } + } else if allowsBundledAsset, let source, let uiImage = UIImage(named: source) { + Image(uiImage: uiImage).resizable() + } else { + fallback.resizable() + } + } +} diff --git a/Core/CoreTests/Generated/CoreMocks.generated.swift b/Core/CoreTests/Generated/CoreMocks.generated.swift index f823ea072..58f6d6d44 100644 --- a/Core/CoreTests/Generated/CoreMocks.generated.swift +++ b/Core/CoreTests/Generated/CoreMocks.generated.swift @@ -340,6 +340,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/Course/CourseTests/Generated/CourseMocks.generated.swift b/Course/CourseTests/Generated/CourseMocks.generated.swift index d52aa4b5a..bd7a32391 100644 --- a/Course/CourseTests/Generated/CourseMocks.generated.swift +++ b/Course/CourseTests/Generated/CourseMocks.generated.swift @@ -342,6 +342,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift b/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift index 6edc7b5ff..43923d515 100644 --- a/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift +++ b/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift @@ -341,6 +341,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift b/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift index 12cf97384..b9349d947 100644 --- a/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift +++ b/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift @@ -341,6 +341,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift b/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift index 90bf58493..4cb5f3835 100644 --- a/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift +++ b/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift @@ -341,6 +341,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift b/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift index 01e2dba87..77044bc2f 100644 --- a/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift +++ b/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift @@ -341,6 +341,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index cca2cca0e..330f92b73 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -91,9 +91,20 @@ class ScreenAssembly: Assembly { analytics: r.resolve(AuthorizationAnalytics.self)!, validator: r.resolve(Validator.self)!, storage: r.resolve(CoreStorage.self)!, + instanceProvider: r.resolve(InstanceProvider.self)!, sourceScreen: sourceScreen ) } + container.register(LearningSitesViewModel.self) { @MainActor r, isSwitching in + LearningSitesViewModel( + instanceStore: r.resolve(InstanceStore.self)!, + storage: r.resolve(CoreStorage.self)!, + sessionManager: r.resolve(InstanceSessionManagerProtocol.self)!, + router: r.resolve(AuthorizationRouter.self)!, + isSwitching: isSwitching + ) + } + container.register(SSOWebViewModel.self) { @MainActor r in SSOWebViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, @@ -283,7 +294,7 @@ class ScreenAssembly: Assembly { container.register(SettingsViewModel.self) { @MainActor r in SettingsViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, - downloadManager: r.resolve(DownloadManagerProtocol.self)!, + sessionManager: r.resolve(InstanceSessionManagerProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, coreAnalytics: r.resolve(CoreAnalytics.self)!, diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 99ad76691..e70efe23a 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -404,6 +404,19 @@ public final class AppStorage: CoreStorage, } } + /// Checks a specific instance's session without switching the active instance -- + /// namespaced keychain entries already exist per instance, this just reads one directly. + public func hasSession(forInstanceKey key: String) -> Bool { + keychain.get("\(key).\(KEY_ACCESS_TOKEN)") != nil + } + + /// Clears a specific instance's tokens without switching the active instance or touching + /// its downloaded content -- the non-current-instance counterpart to `clear()`. + public func clearSession(forInstanceKey key: String) { + keychain.delete("\(key).\(KEY_ACCESS_TOKEN)") + keychain.delete("\(key).\(KEY_REFRESH_TOKEN)") + } + private let KEY_ACCESS_TOKEN = "accessToken" private let KEY_REFRESH_TOKEN = "refreshToken" private let KEY_PUSH_TOKEN = "pushToken" diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index f9a377ffe..694cdca55 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -29,6 +29,10 @@ class RouteController: UIViewController { private lazy var coreAnalytics: CoreAnalytics = { diContainer.resolve(CoreAnalytics.self)! }() + + private lazy var instanceStore: InstanceStore = { + diContainer.resolve(InstanceStore.self)! + }() override func viewDidLoad() { super.viewDidLoad() @@ -49,6 +53,20 @@ class RouteController: UIViewController { } private func showStartupScreen() { + // A multi-instance catalog with nothing selected yet needs the site picker, not + // Startup/SignIn -- those assume a single (app-level) destination to sign into. + // A single-instance catalog never reaches here: InstanceStore auto-selects it. + if instanceStore.currentInstance == nil, instanceStore.instancesConfig.instances.count > 1 { + let controller = UIHostingController( + rootView: LearningSitesView( + viewModel: diContainer.resolve(LearningSitesViewModel.self, argument: false)! + ) + ) + navigation.viewControllers = [controller] + present(navigation, animated: false) + return + } + if let config = Container.shared.resolve(ConfigProtocol.self), config.features.startupScreenEnabled { let controller = UIHostingController( rootView: StartupView(viewModel: diContainer.resolve(StartupViewModel.self)!)) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 2da89bd63..4caa6f8ac 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -132,6 +132,21 @@ public class Router: AuthorizationRouter, } public func showStartupScreen() { + // Same branch as RouteController.showStartupScreen() -- a multi-instance catalog + // with nothing selected needs the site picker, not Startup/SignIn. Reached when a + // full logout (e.g. Settings' Log Out) clears the selection while other sites are + // still in the catalog. + if let instanceStore = Container.shared.resolve(InstanceStore.self), + instanceStore.currentInstance == nil, + instanceStore.instancesConfig.instances.count > 1 { + let view = LearningSitesView( + viewModel: Container.shared.resolve(LearningSitesViewModel.self, argument: false)! + ) + let controller = UIHostingController(rootView: view) + navigationController.setViewControllers([controller], animated: true) + return + } + if let config = Container.shared.resolve(ConfigProtocol.self), config.features.startupScreenEnabled { let view = StartupView(viewModel: Container.shared.resolve(StartupViewModel.self)!) let controller = UIHostingController(rootView: view) @@ -805,6 +820,13 @@ public class Router: AuthorizationRouter, let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } + + public func showLearningSites() { + let viewModel = Container.shared.resolve(LearningSitesViewModel.self, argument: true)! + let view = LearningSitesView(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } public func showVideoQualityView(viewModel: SettingsViewModel) { let view = VideoQualityView(viewModel: viewModel) diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index b87a24ca9..17ffcc121 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -24,7 +24,9 @@ public protocol ProfileRouter: BaseRouter { func showVideoSettings() func showManageAccount() - + + func showLearningSites() + func showDatesAndCalendar() func showSyncCalendarOptions() @@ -66,7 +68,9 @@ public class ProfileRouterPreview: BaseRouterMock, ProfileRouter { public func showCoursesToSync() {} public func showManageAccount() {} - + + public func showLearningSites() {} + public func showVideoQualityView(viewModel: SettingsViewModel) {} public func showVideoDownloadQualityView( diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 957b81b5c..dc9bb7075 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -66,6 +66,7 @@ public struct SettingsView: View { .accessibilityIdentifier("progress_bar") } else { manageAccount + learningSites settings datesAndCalendar ProfileSupportInfoView(viewModel: viewModel) @@ -163,6 +164,32 @@ public struct SettingsView: View { ) } + // MARK: - Learning Sites + + @ViewBuilder + private var learningSites: some View { + VStack(alignment: .leading, spacing: 27) { + Button(action: { + viewModel.router.showLearningSites() + }, label: { + HStack { + Text(ProfileLocalization.learningSites) + .font(Theme.Fonts.titleMedium) + Spacer() + Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) + } + }) + .accessibilityIdentifier("learning_sites_button") + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.learningSites) + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + } + // MARK: - Settings @ViewBuilder @@ -247,7 +274,7 @@ public struct SettingsView: View { let router = ProfileRouterPreview() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, - downloadManager: DownloadManagerMock(), + sessionManager: InstanceSessionManagerProtocolMock(), router: router, analytics: ProfileAnalyticsPreview(), coreAnalytics: CoreAnalyticsMock(), diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 2aae0526b..1a5b64f50 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -69,7 +69,7 @@ public final class SettingsViewModel { private(set) var userSettings: UserSettings private let interactor: ProfileInteractorProtocol - private let downloadManager: DownloadManagerProtocol + private let sessionManager: InstanceSessionManagerProtocol let router: ProfileRouter let analytics: ProfileAnalytics let coreAnalytics: CoreAnalytics @@ -80,7 +80,7 @@ public final class SettingsViewModel { public init( interactor: ProfileInteractorProtocol, - downloadManager: DownloadManagerProtocol, + sessionManager: InstanceSessionManagerProtocol, router: ProfileRouter, analytics: ProfileAnalytics, coreAnalytics: CoreAnalytics, @@ -90,7 +90,7 @@ public final class SettingsViewModel { coreStorage: CoreStorage ) { self.interactor = interactor - self.downloadManager = downloadManager + self.sessionManager = sessionManager self.router = router self.analytics = analytics self.coreAnalytics = coreAnalytics @@ -166,7 +166,7 @@ public final class SettingsViewModel { func logOut() async { try? await interactor.logOut() - try? await downloadManager.cancelAllDownloading() + await sessionManager.logoutCurrentInstance() await corePersistence.deleteAllProgress() router.showStartupScreen() analytics.userLogout(force: false) diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 551077d0e..acbcb68bc 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -127,7 +127,7 @@ public struct VideoQualityView: View { let router = ProfileRouterPreview() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, - downloadManager: DownloadManagerMock(), + sessionManager: InstanceSessionManagerProtocolMock(), router: router, analytics: ProfileAnalyticsPreview(), coreAnalytics: CoreAnalyticsMock(), diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index 3b12032f7..18154bdeb 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -134,7 +134,7 @@ public struct VideoSettingsView: View { let router = ProfileRouterPreview() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, - downloadManager: DownloadManagerMock(), + sessionManager: InstanceSessionManagerProtocolMock(), router: router, analytics: ProfileAnalyticsPreview(), coreAnalytics: CoreAnalyticsMock(), diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 3401ceb79..686755c20 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -36,6 +36,8 @@ public enum ProfileLocalization { public static let logout = ProfileLocalization.tr("Localizable", "LOGOUT", fallback: "Log out") /// Manage Account public static let manageAccount = ProfileLocalization.tr("Localizable", "MANAGE_ACCOUNT", fallback: "Manage Account") + /// Learning Sites + public static let learningSites = ProfileLocalization.tr("Localizable", "LEARNING_SITES", fallback: "Learning Sites") /// Privacy policy public static let privacy = ProfileLocalization.tr("Localizable", "PRIVACY", fallback: "Privacy policy") /// Settings diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 8a91753ee..2c35bb286 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -23,6 +23,7 @@ "DO_NOT_SELL_INFORMATION" = "Do not sell my personal information"; "FAQ_TITLE" = "View FAQ"; "MANAGE_ACCOUNT" = "Manage Account"; +"LEARNING_SITES" = "Learning Sites"; "LOGOUT" = "Log out"; "SWITCH_TO" = "Switch to"; diff --git a/Profile/ProfileTests/Generated/ProfileMocks.generated.swift b/Profile/ProfileTests/Generated/ProfileMocks.generated.swift index c6c413506..0e639d62c 100644 --- a/Profile/ProfileTests/Generated/ProfileMocks.generated.swift +++ b/Profile/ProfileTests/Generated/ProfileMocks.generated.swift @@ -342,6 +342,14 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { } } + + public var sessionInstanceKeys: Set = [] + public func hasSession(forInstanceKey key: String) -> Bool { + sessionInstanceKeys.contains(key) + } + public func clearSession(forInstanceKey key: String) { + sessionInstanceKeys.remove(key) + } } public final class AuthInteractorProtocolMock: AuthInteractorProtocol, @unchecked Sendable { @@ -1784,6 +1792,25 @@ public final class ProfileRouterMock: ProfileRouter, @unchecked Sendable { } + private let showLearningSitesState = MockoloMutex(MockoloHandlerState ()>()) + public var showLearningSitesCallCount: Int { + return showLearningSitesState.withLock(\.callCount) + } + public var showLearningSitesHandler: (@Sendable () -> ())? { + get { showLearningSitesState.withLock(\.handler) } + set { showLearningSitesState.withLock { $0.handler = newValue } } + } + public func showLearningSites() { + let showLearningSitesHandler = showLearningSitesState.withLock { state in + state.callCount += 1 + return state.handler + } + if let showLearningSitesHandler = showLearningSitesHandler { + showLearningSitesHandler() + } + + } + private let showDatesAndCalendarState = MockoloMutex(MockoloHandlerState ()>()) public var showDatesAndCalendarCallCount: Int { return showDatesAndCalendarState.withLock(\.callCount) diff --git a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift index 47e1880f9..e9da91906 100644 --- a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift @@ -20,7 +20,7 @@ final class SettingsViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let coreAnalytics = CoreAnalyticsMock() let storage = CoreStorageMock() - let downloadManager = DownloadManagerProtocolMock() + let sessionManager = InstanceSessionManagerProtocolMock() let corePersistence = CorePersistenceProtocolMock() let connectivity = ConnectivityProtocolMock() let config = ConfigProtocolMock() @@ -39,7 +39,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, - downloadManager: downloadManager, + sessionManager: sessionManager, router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -51,6 +51,7 @@ final class SettingsViewModelTests: XCTestCase { await viewModel.logOut() + XCTAssertEqual(sessionManager.logoutCurrentInstanceCallCount, 1) XCTAssertEqual(router.showStartupScreenCallCount, 1) XCTAssertFalse(viewModel.showError) } @@ -61,7 +62,7 @@ final class SettingsViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let coreAnalytics = CoreAnalyticsMock() let storage = CoreStorageMock() - let downloadManager = DownloadManagerProtocolMock() + let sessionManager = InstanceSessionManagerProtocolMock() let corePersistence = CorePersistenceProtocolMock() let connectivity = ConnectivityProtocolMock() let config = ConfigProtocolMock() @@ -78,7 +79,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, - downloadManager: downloadManager, + sessionManager: sessionManager, router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -99,7 +100,7 @@ final class SettingsViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let coreAnalytics = CoreAnalyticsMock() let storage = CoreStorageMock() - let downloadManager = DownloadManagerProtocolMock() + let sessionManager = InstanceSessionManagerProtocolMock() let corePersistence = CorePersistenceProtocolMock() let connectivity = ConnectivityProtocolMock() let config = ConfigProtocolMock() @@ -116,7 +117,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, - downloadManager: downloadManager, + sessionManager: sessionManager, router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -137,7 +138,7 @@ final class SettingsViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let coreAnalytics = CoreAnalyticsMock() let storage = CoreStorageMock() - let downloadManager = DownloadManagerProtocolMock() + let sessionManager = InstanceSessionManagerProtocolMock() let corePersistence = CorePersistenceProtocolMock() let connectivity = ConnectivityProtocolMock() let config = ConfigProtocolMock() @@ -154,7 +155,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, - downloadManager: downloadManager, + sessionManager: sessionManager, router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -175,7 +176,7 @@ final class SettingsViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let coreAnalytics = CoreAnalyticsMock() let storage = CoreStorageMock() - let downloadManager = DownloadManagerProtocolMock() + let sessionManager = InstanceSessionManagerProtocolMock() let corePersistence = CorePersistenceProtocolMock() let connectivity = ConnectivityProtocolMock() let config = ConfigProtocolMock() @@ -192,7 +193,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, - downloadManager: downloadManager, + sessionManager: sessionManager, router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -213,7 +214,7 @@ final class SettingsViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let coreAnalytics = CoreAnalyticsMock() let storage = CoreStorageMock() - let downloadManager = DownloadManagerProtocolMock() + let sessionManager = InstanceSessionManagerProtocolMock() let corePersistence = CorePersistenceProtocolMock() let connectivity = ConnectivityProtocolMock() let config = ConfigProtocolMock() @@ -230,7 +231,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, - downloadManager: downloadManager, + sessionManager: sessionManager, router: router, analytics: analytics, coreAnalytics: coreAnalytics, From c1f8e18dbbce935c4dc39ae52c7923d6084419c0 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:30:11 +0000 Subject: [PATCH 2/5] feat: apply the instance's accent color app-wide Accent color now updates everywhere the app uses it, not just Sign In, using Theme.Colors/Theme.UIColors' existing update() mechanism. Wired into instance switch, logout, and app launch. --- .../InstanceSessionManager.swift | 40 +++++++++++++++++- .../InstanceSessionManagerTests.swift | 18 ++++++++ OpenEdX/AppDelegate.swift | 4 ++ Theme/Theme.xcodeproj/project.pbxproj | 8 ++++ Theme/Theme/Helpers/UIColor+Hex.swift | 41 +++++++++++++++++++ Theme/ThemeTests/UIColorHexTests.swift | 40 ++++++++++++++++++ 6 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 Theme/Theme/Helpers/UIColor+Hex.swift create mode 100644 Theme/ThemeTests/UIColorHexTests.swift diff --git a/Core/Core/Configuration/InstanceSessionManager.swift b/Core/Core/Configuration/InstanceSessionManager.swift index a225c9f9d..84bfd51d8 100644 --- a/Core/Core/Configuration/InstanceSessionManager.swift +++ b/Core/Core/Configuration/InstanceSessionManager.swift @@ -6,6 +6,8 @@ // import Foundation +import SwiftUI +import Theme /// @mockable public protocol InstanceSessionManagerProtocol: Sendable { @@ -22,6 +24,11 @@ public protocol InstanceSessionManagerProtocol: Sendable { /// when it's the active instance; otherwise just clears that instance's stored tokens, /// leaving the active session untouched. func logout(_ instance: Instance) async + + /// Applies the current instance's accent color (or resets to the app default if none is + /// selected). Called once at launch after the catalog resolves -- a persisted-selection + /// restore doesn't go through `switchActiveInstance(to:)`, so nothing else triggers it. + func applyThemeForCurrentInstance() } public final class InstanceSessionManager: InstanceSessionManagerProtocol { @@ -46,7 +53,7 @@ public final class InstanceSessionManager: InstanceSessionManagerProtocol { // No teardown -- storage, CoreData, and downloads stay untouched. instanceStore.select(instance) - // No ThemeManager yet -- apply per-instance theme here once it exists. + applyThemeForCurrentInstance() } public func logoutCurrentInstance() async { @@ -62,7 +69,7 @@ public final class InstanceSessionManager: InstanceSessionManagerProtocol { // Clear selection last, so nothing observes a half-logged-out state. instanceStore.select(nil) - // No ThemeManager yet -- reset theme here once it exists. + applyThemeForCurrentInstance() // CoreData rows and downloaded files are left in place on purpose -- // see CoreDataHandlerProtocol.clear(instanceKey:) for an explicit wipe. @@ -75,6 +82,30 @@ public final class InstanceSessionManager: InstanceSessionManagerProtocol { storage.clearSession(forInstanceKey: instance.key) } } + + public func applyThemeForCurrentInstance() { + guard let accentColor = Self.resolveAccentColor(for: instanceStore.currentInstance) else { + // Nothing selected, or the instance didn't supply an accent_color -- back to + // the app-level default (every other Theme.Colors.update() param already + // defaults to it). + Theme.Colors.update() + Theme.UIColors.update() + return + } + Theme.Colors.update(accentColor: Color(uiColor: accentColor)) + Theme.UIColors.update(accentColor: accentColor) + } + + /// A single UIColor that resolves to the instance's light/dark "accent_color" hex per the + /// active trait collection -- SwiftUI's `Color(uiColor:)` stays adaptive through this. + private static func resolveAccentColor(for instance: Instance?) -> UIColor? { + guard let lightHex = instance?.themeColors?.light["accent_color"], + let light = UIColor(hex: lightHex) else { + return nil + } + let dark = instance?.themeColors?.dark["accent_color"].flatMap { UIColor(hex: $0) } ?? light + return UIColor { $0.userInterfaceStyle == .dark ? dark : light } + } } #if DEBUG @@ -103,5 +134,10 @@ public final class InstanceSessionManagerProtocolMock: InstanceSessionManagerPro lastLoggedOutInstance = instance logoutHandler?(instance) } + + public private(set) var applyThemeForCurrentInstanceCallCount = 0 + public func applyThemeForCurrentInstance() { + applyThemeForCurrentInstanceCallCount += 1 + } } #endif diff --git a/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift b/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift index aa29441b6..1e27bf3bd 100644 --- a/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift +++ b/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift @@ -7,6 +7,7 @@ import XCTest import Foundation +import Theme @testable import Core private final class PushTokenUnregisteringSpy: PushTokenUnregistering, @unchecked Sendable { @@ -83,4 +84,21 @@ final class InstanceSessionManagerTests: XCTestCase { XCTAssertEqual(storage.clearCallCount, 1) XCTAssertNil(instanceStore.currentInstance) } + + // Switching to an instance with its own accent_color applies it app-wide via the + // existing Theme.Colors/UIColors.update() mechanism; logging out resets to the default. + func testSwitchActiveInstance_WithAccentColor_UpdatesThemeThenLogoutResets() async { + let branded = Instance(dictionary: [ + "NAME": "Branded", "OAUTH_CLIENT_ID": "branded-id", "API_HOST_URL": "https://branded.example.com", + "THEME": ["LIGHT": ["accent_color": "#112233"]] + ])! + + await sut.switchActiveInstance(to: branded) + + XCTAssertEqual(Theme.UIColors.accentColor.cgColor, UIColor(hex: "#112233")!.cgColor) + + await sut.logoutCurrentInstance() + + XCTAssertEqual(Theme.UIColors.accentColor.cgColor, ThemeAssets.accentColor.color.cgColor) + } } diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index de84e583e..605e2696e 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -174,6 +174,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } let config = await loader.load() instanceStore.updateInstancesConfig(config) + + // A persisted selection is restored inside updateInstancesConfig() above without + // going through switchActiveInstance(to:), so nothing else applies its theme colors. + Container.shared.resolve(InstanceSessionManagerProtocol.self)?.applyThemeForCurrentInstance() } private func initDI() { diff --git a/Theme/Theme.xcodeproj/project.pbxproj b/Theme/Theme.xcodeproj/project.pbxproj index f073cc71f..5c72635fa 100644 --- a/Theme/Theme.xcodeproj/project.pbxproj +++ b/Theme/Theme.xcodeproj/project.pbxproj @@ -13,11 +13,13 @@ 0770DE7B28D0C78C006D8A5D /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7A28D0C78C006D8A5D /* Theme.swift */; }; 4875747BC644481E79603124 /* Pods_App_Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51EFD5ECB23A4898127124B9 /* Pods_App_Theme.framework */; }; E04A731A2B171D2B00E31764 /* FontParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09179F82B0EF981002AB695 /* FontParserTests.swift */; }; + 2A3E81F320D84BFC8A214AE1 /* UIColorHexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D838B177D324C20A195BE14 /* UIColorHexTests.swift */; }; E0C716A72B0B05BA00858BAB /* fonts.json in Resources */ = {isa = PBXBuildFile; fileRef = E0C716A62B0B05BA00858BAB /* fonts.json */; }; E0C716A92B0B065600858BAB /* FontParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0C716A82B0B065600858BAB /* FontParser.swift */; }; E0C716AB2B0B0B8E00858BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E0C716AA2B0B0B8E00858BAB /* Assets.xcassets */; }; E0D6E69F2B161BD70089F9C9 /* ThemeAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D6E69E2B161BD70089F9C9 /* ThemeAssets.swift */; }; E0D6E6A72B16DD4E0089F9C9 /* RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D6E6A62B16DD4E0089F9C9 /* RoundedCorners.swift */; }; + D8BDB62C7A984EEB82E2B7AD /* UIColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77397399EEE64A5E9C59DB05 /* UIColor+Hex.swift */; }; E0D6E6B22B16EA380089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE0828D07831006D8A5D /* Theme.framework */; platformFilter = ios; }; /* End PBXBuildFile section */ @@ -63,11 +65,13 @@ C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; E001ADD92B1767C000DE3A03 /* Theme.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Theme.xctestplan; path = ../Theme.xctestplan; sourceTree = ""; }; E09179F82B0EF981002AB695 /* FontParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontParserTests.swift; sourceTree = ""; }; + 4D838B177D324C20A195BE14 /* UIColorHexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColorHexTests.swift; sourceTree = ""; }; E0C716A62B0B05BA00858BAB /* fonts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fonts.json; sourceTree = ""; }; E0C716A82B0B065600858BAB /* FontParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontParser.swift; sourceTree = ""; }; E0C716AA2B0B0B8E00858BAB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E0D6E69E2B161BD70089F9C9 /* ThemeAssets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeAssets.swift; sourceTree = ""; }; E0D6E6A62B16DD4E0089F9C9 /* RoundedCorners.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedCorners.swift; sourceTree = ""; }; + 77397399EEE64A5E9C59DB05 /* UIColor+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Hex.swift"; sourceTree = ""; }; E0D6E6AE2B16EA380089F9C9 /* ThemeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ThemeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F6E09DC289758CE811EF93D8 /* Pods-App-Theme-ThemeTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Theme-ThemeTests.release.xcconfig"; path = "Target Support Files/Pods-App-Theme-ThemeTests/Pods-App-Theme-ThemeTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -181,6 +185,7 @@ isa = PBXGroup; children = ( E09179F82B0EF981002AB695 /* FontParserTests.swift */, + 4D838B177D324C20A195BE14 /* UIColorHexTests.swift */, ); path = ThemeTests; sourceTree = ""; @@ -189,6 +194,7 @@ isa = PBXGroup; children = ( E0D6E6A62B16DD4E0089F9C9 /* RoundedCorners.swift */, + 77397399EEE64A5E9C59DB05 /* UIColor+Hex.swift */, ); path = Helpers; sourceTree = ""; @@ -386,6 +392,7 @@ buildActionMask = 2147483647; files = ( E0D6E6A72B16DD4E0089F9C9 /* RoundedCorners.swift in Sources */, + D8BDB62C7A984EEB82E2B7AD /* UIColor+Hex.swift in Sources */, E0C716A92B0B065600858BAB /* FontParser.swift in Sources */, 0770DE7B28D0C78C006D8A5D /* Theme.swift in Sources */, E0D6E69F2B161BD70089F9C9 /* ThemeAssets.swift in Sources */, @@ -397,6 +404,7 @@ buildActionMask = 2147483647; files = ( E04A731A2B171D2B00E31764 /* FontParserTests.swift in Sources */, + 2A3E81F320D84BFC8A214AE1 /* UIColorHexTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Theme/Theme/Helpers/UIColor+Hex.swift b/Theme/Theme/Helpers/UIColor+Hex.swift new file mode 100644 index 000000000..1d6fe400e --- /dev/null +++ b/Theme/Theme/Helpers/UIColor+Hex.swift @@ -0,0 +1,41 @@ +// +// UIColor+Hex.swift +// Theme +// +// Created by Rawan Matar on 22/09/2026. +// + +import UIKit + +extension UIColor { + /// Parses a "#RRGGBB"/"RRGGBB" or "#RRGGBBAA"/"RRGGBBAA" hex string. `nil` on anything + /// else -- used for instance-supplied accent colors, which aren't guaranteed well-formed. + public convenience init?(hex: String) { + var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if hexString.hasPrefix("#") { hexString.removeFirst() } + guard hexString.count == 6 || hexString.count == 8, let value = UInt64(hexString, radix: 16) else { + return nil + } + + let hasAlpha = hexString.count == 8 + let red, green, blue, alpha: UInt64 + if hasAlpha { + red = (value >> 24) & 0xFF + green = (value >> 16) & 0xFF + blue = (value >> 8) & 0xFF + alpha = value & 0xFF + } else { + red = (value >> 16) & 0xFF + green = (value >> 8) & 0xFF + blue = value & 0xFF + alpha = 0xFF + } + + self.init( + red: CGFloat(red) / 255, + green: CGFloat(green) / 255, + blue: CGFloat(blue) / 255, + alpha: CGFloat(alpha) / 255 + ) + } +} diff --git a/Theme/ThemeTests/UIColorHexTests.swift b/Theme/ThemeTests/UIColorHexTests.swift new file mode 100644 index 000000000..e15a5c003 --- /dev/null +++ b/Theme/ThemeTests/UIColorHexTests.swift @@ -0,0 +1,40 @@ +// +// UIColorHexTests.swift +// ThemeTests +// +// Created by Rawan Matar on 22/09/2026. +// + +import XCTest +@testable import Theme + +final class UIColorHexTests: XCTestCase { + + private func components(_ color: UIColor) -> (CGFloat, CGFloat, CGFloat, CGFloat) { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + return (r, g, b, a) + } + + func test_sixDigitHex_withHash_parsesOpaque() { + let color = UIColor(hex: "#FF0000") + XCTAssertEqual(components(color!), (1, 0, 0, 1)) + } + + func test_sixDigitHex_withoutHash_parses() { + XCTAssertNotNil(UIColor(hex: "00FF00")) + } + + func test_eightDigitHex_parsesWithAlpha() { + let color = UIColor(hex: "#0000FF80") + let (_, _, b, a) = components(color!) + XCTAssertEqual(b, 1) + XCTAssertEqual(a, 128.0 / 255, accuracy: 0.01) + } + + func test_malformedHex_returnsNil() { + XCTAssertNil(UIColor(hex: "notacolor")) + XCTAssertNil(UIColor(hex: "#FFF")) + XCTAssertNil(UIColor(hex: "")) + } +} From a693facdf0df44df9eea35398a2ec565eb99d142 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:30:11 +0000 Subject: [PATCH 3/5] fix: polish Learning Sites picker and sign-in flow - match Figma design for Learning Sites / Sign In, fix accent color - fix sign-in request going to the wrong instance's host - fix build error -- NSCache static isn't Sendable - fix crash selecting a site -- UIKit touched off the main thread - fix app reporting offline after a real login - fix remaining frozen-config-value singletons --- .../LearningSites/LearningSitesView.swift | 56 ++++++++++++----- .../LearningSitesViewModel.swift | 4 +- .../Presentation/Login/SignInView.swift | 43 ++++++++++++- .../Authorization/SwiftGen/Strings.swift | 54 +++++++++------- .../en.lproj/Localizable.strings | 7 ++- Core/Core/Configuration/CSSInjector.swift | 11 +++- Core/Core/Configuration/Connectivity.swift | 13 +++- .../InstanceSessionManager.swift | 35 +++++++++-- Core/Core/Network/RequestInterceptor.swift | 11 +++- Core/Core/View/Base/InstanceThemedImage.swift | 63 ++++++++++++++++--- .../View/Base/LogistrationBottomView.swift | 3 + .../InstanceSessionManagerTests.swift | 21 +++++++ Core/CoreTests/RequestInterceptorTests.swift | 22 +++++++ OpenEdX/AppDelegate.swift | 14 +++++ OpenEdX/DI/AppAssembly.swift | 5 +- OpenEdX/Managers/PipManager.swift | 10 ++- 16 files changed, 306 insertions(+), 66 deletions(-) diff --git a/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift b/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift index 0a8dde9fc..d3d60bb77 100644 --- a/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift +++ b/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift @@ -30,13 +30,13 @@ public struct LearningSitesView: View { .accessibilityIdentifier("auth_bg_image") VStack(alignment: .center) { - ZStack { - HStack { - Text(AuthLocalization.LearningSites.title) - .titleSettings(color: Theme.Colors.loginNavigationText) - .accessibilityIdentifier("learning_sites_title_text") - } - if viewModel.isSwitching { + if viewModel.isSwitching { + ZStack { + HStack { + Text(AuthLocalization.LearningSites.title) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("learning_sites_title_text") + } VStack { BackNavigationButton( color: Theme.Colors.loginNavigationText, @@ -47,10 +47,32 @@ public struct LearningSitesView: View { .accessibilityIdentifier("back_button") }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) } + } else { + ThemeAssets.appLogo.swiftUIImage + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 189, maxHeight: 89) + .padding(.top, isHorizontal ? 20 : 40) + .padding(.bottom, isHorizontal ? 10 : 40) + .accessibilityIdentifier("logo_image") } ScrollView { VStack(alignment: .leading, spacing: 24) { + if !viewModel.isSwitching { + VStack(alignment: .leading, spacing: 4) { + Text(AuthLocalization.LearningSites.getStarted) + .font(Theme.Fonts.displaySmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("get_started_text") + + Text(AuthLocalization.LearningSites.welcomeSubtitle) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("welcome_subtitle_text") + } + } + if !viewModel.currentLearningSites.isEmpty { sitesSection( title: AuthLocalization.LearningSites.currentSite, @@ -60,9 +82,7 @@ public struct LearningSitesView: View { } sitesSection( - title: viewModel.currentLearningSites.isEmpty - ? AuthLocalization.LearningSites.title - : AuthLocalization.LearningSites.moreSites, + title: AuthLocalization.LearningSites.searchTitle, sites: viewModel.addableSites, showsLogOut: false, searchField: true @@ -147,11 +167,16 @@ public struct LearningSitesView: View { fallback: ThemeAssets.appLogo.swiftUIImage ) .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - - Text(instance.name) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) + .frame(width: 50, height: 50) + + VStack(alignment: .leading, spacing: 2) { + Text(instance.name) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + Text(instance.baseURL.host ?? instance.baseURL.absoluteString) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textSecondary) + } Spacer() @@ -163,6 +188,7 @@ public struct LearningSitesView: View { .accessibilityIdentifier("log_out_site_button") } } + .frame(height: 60) .contentShape(Rectangle()) .onTapGesture { Task { await viewModel.select(instance) } diff --git a/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift b/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift index 9e8316b39..38d87f40a 100644 --- a/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift +++ b/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift @@ -79,9 +79,9 @@ import Core let alreadySignedIn = hasSession(instance) await sessionManager.switchActiveInstance(to: instance) if alreadySignedIn { - router.showMainOrWhatsNewScreen(sourceScreen: .default, postLoginData: nil) + router.showMainOrWhatsNewScreen(sourceScreen: .learningSites, postLoginData: nil) } else { - router.showLoginScreen(sourceScreen: .default) + router.showLoginScreen(sourceScreen: .learningSites) } } diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index bdd92a009..d2b75873a 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -34,7 +34,7 @@ public struct SignInView: View { .edgesIgnoringSafeArea(.top) .accessibilityIdentifier("auth_bg_image") }.frame(maxWidth: .infinity, maxHeight: 200) - if viewModel.config.features.startupScreenEnabled { + if viewModel.config.features.startupScreenEnabled || viewModel.sourceScreen == .learningSites { VStack { BackNavigationButton( color: Theme.Colors.loginNavigationText, @@ -66,6 +66,11 @@ public struct SignInView: View { ScrollView { VStack { VStack(alignment: .leading) { + if let instance = viewModel.currentInstance { + selectedSiteSection(instance) + .padding(.bottom, 20) + .accessibilityIdentifier("selected_site_section") + } if viewModel.config.uiComponents.loginRegistrationEnabled { Text(AuthLocalization.SignIn.logInTitle) .font(Theme.Fonts.displaySmall) @@ -298,6 +303,42 @@ public struct SignInView: View { } } + private func selectedSiteSection(_ instance: Instance) -> some View { + HStack(spacing: 12) { + InstanceThemedImage( + source: instance.logoURLString, + allowsBundledAsset: true, + fallback: ThemeAssets.appLogo.swiftUIImage + ) + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(AuthLocalization.SignIn.selectedSite) + .font(Theme.Fonts.labelSmall) + .foregroundColor(Theme.Colors.textSecondary) + Text(instance.name) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + } + + Spacer() + + Button(AuthLocalization.SignIn.change) { + viewModel.router.back() + } + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + .accessibilityIdentifier("change_site_button") + } + .padding(12) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + } + @ViewBuilder private var agreements: some View { if let eulaURL = viewModel.config.agreement.eulaURL, diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index dd3218c7e..f43f26f99 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -54,12 +54,42 @@ public enum AuthLocalization { /// Forgot password public static let title = AuthLocalization.tr("Localizable", "FORGOT.TITLE", fallback: "Forgot password") } + public enum LearningSites { + /// Add a Learning Site + public static let addSite = AuthLocalization.tr("Localizable", "LEARNING_SITES.ADD_SITE", fallback: "Add a Learning Site") + /// Current Learning Site + public static let currentSite = AuthLocalization.tr("Localizable", "LEARNING_SITES.CURRENT_SITE", fallback: "Current Learning Site") + /// No schools match your search + public static let emptySearch = AuthLocalization.tr("Localizable", "LEARNING_SITES.EMPTY_SEARCH", fallback: "No schools match your search") + /// Get Started + public static let getStarted = AuthLocalization.tr("Localizable", "LEARNING_SITES.GET_STARTED", fallback: "Get Started") + /// Log Out + public static let logOutAction = AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_ACTION", fallback: "Log Out") + /// Are you sure you want to log out of %@? + public static func logOutMessage(_ p1: Any) -> String { + return AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_MESSAGE", String(describing: p1), fallback: "Are you sure you want to log out of %@?") + } + /// Log Out + public static let logOutTitle = AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_TITLE", fallback: "Log Out") + /// More Learning Sites + public static let moreSites = AuthLocalization.tr("Localizable", "LEARNING_SITES.MORE_SITES", fallback: "More Learning Sites") + /// University of Arts & Sciences + public static let searchPlaceholder = AuthLocalization.tr("Localizable", "LEARNING_SITES.SEARCH_PLACEHOLDER", fallback: "University of Arts & Sciences") + /// Search + public static let searchTitle = AuthLocalization.tr("Localizable", "LEARNING_SITES.SEARCH_TITLE", fallback: "Search") + /// Learning Sites + public static let title = AuthLocalization.tr("Localizable", "LEARNING_SITES.TITLE", fallback: "Learning Sites") + /// Welcome! Select your learning site + public static let welcomeSubtitle = AuthLocalization.tr("Localizable", "LEARNING_SITES.WELCOME_SUBTITLE", fallback: "Welcome! Select your learning site") + } public enum SignIn { /// By signing in to this app, you agree to the [%@ End User License Agreement](%@) and [%@ Terms of Service and Honor Code](%@) and you acknowledge that %@ and each Member process your personal data in /// accordance with the [Privacy Policy.](%@) public static func agreement(_ p1: Any, _ p2: Any, _ p3: Any, _ p4: Any, _ p5: Any, _ p6: Any) -> String { return AuthLocalization.tr("Localizable", "SIGN_IN.AGREEMENT", String(describing: p1), String(describing: p2), String(describing: p3), String(describing: p4), String(describing: p5), String(describing: p6), fallback: "By signing in to this app, you agree to the [%@ End User License Agreement](%@) and [%@ Terms of Service and Honor Code](%@) and you acknowledge that %@ and each Member process your personal data in\naccordance with the [Privacy Policy.](%@)") } + /// Change + public static let change = AuthLocalization.tr("Localizable", "SIGN_IN.CHANGE", fallback: "Change") /// Email public static let email = AuthLocalization.tr("Localizable", "SIGN_IN.EMAIL", fallback: "Email") /// Email or username @@ -73,6 +103,8 @@ public enum AuthLocalization { public static let logInTitle = AuthLocalization.tr("Localizable", "SIGN_IN.LOG_IN_TITLE", fallback: "Sign in") /// Password public static let password = AuthLocalization.tr("Localizable", "SIGN_IN.PASSWORD", fallback: "Password") + /// Selected Site + public static let selectedSite = AuthLocalization.tr("Localizable", "SIGN_IN.SELECTED_SITE", fallback: "Selected Site") /// Start today to build your career with confidence public static let ssoHeading = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_HEADING", fallback: "Start today to build your career with confidence") /// Log in through the national unified sign-on service @@ -118,28 +150,6 @@ public enum AuthLocalization { /// Start public static let title = AuthLocalization.tr("Localizable", "STARTUP.TITLE", fallback: "Start") } - public enum LearningSites { - /// Learning Sites - public static let title = AuthLocalization.tr("Localizable", "LEARNING_SITES.TITLE", fallback: "Learning Sites") - /// Current Learning Site - public static let currentSite = AuthLocalization.tr("Localizable", "LEARNING_SITES.CURRENT_SITE", fallback: "Current Learning Site") - /// More Learning Sites - public static let moreSites = AuthLocalization.tr("Localizable", "LEARNING_SITES.MORE_SITES", fallback: "More Learning Sites") - /// Add a Learning Site - public static let addSite = AuthLocalization.tr("Localizable", "LEARNING_SITES.ADD_SITE", fallback: "Add a Learning Site") - /// Search for your school - public static let searchPlaceholder = AuthLocalization.tr("Localizable", "LEARNING_SITES.SEARCH_PLACEHOLDER", fallback: "Search for your school") - /// No schools match your search - public static let emptySearch = AuthLocalization.tr("Localizable", "LEARNING_SITES.EMPTY_SEARCH", fallback: "No schools match your search") - /// Log Out - public static let logOutTitle = AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_TITLE", fallback: "Log Out") - /// Are you sure you want to log out of %@? - public static func logOutMessage(_ p1: Any) -> String { - return AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_MESSAGE", String(describing: p1), fallback: "Are you sure you want to log out of %@?") - } - /// Log Out - public static let logOutAction = AuthLocalization.tr("Localizable", "LEARNING_SITES.LOG_OUT_ACTION", fallback: "Log Out") - } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index f69fe61dc..cf7483c6b 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -12,6 +12,8 @@ "SIGN_IN.EMAIL_OR_USERNAME" = "Email or username"; "SIGN_IN.PASSWORD" = "Password"; "SIGN_IN.FORGOT_PASS_BTN" = "Forgot password?"; +"SIGN_IN.SELECTED_SITE" = "Selected Site"; +"SIGN_IN.CHANGE" = "Change"; "SIGN_IN.AGREEMENT" = "By signing in to this app, you agree to the [%@ End User License Agreement](%@) and [%@ Terms of Service and Honor Code](%@) and you acknowledge that %@ and each Member process your personal data in accordance with the [Privacy Policy.](%@)"; "SIGN_IN.SSO_HEADING" = "Start today to build your career with confidence"; @@ -57,10 +59,13 @@ accordance with the [Privacy Policy.](%@)"; "STARTUP.TITLE" = "Start"; "LEARNING_SITES.TITLE" = "Learning Sites"; +"LEARNING_SITES.GET_STARTED" = "Get Started"; +"LEARNING_SITES.WELCOME_SUBTITLE" = "Welcome! Select your learning site"; +"LEARNING_SITES.SEARCH_TITLE" = "Search"; "LEARNING_SITES.CURRENT_SITE" = "Current Learning Site"; "LEARNING_SITES.MORE_SITES" = "More Learning Sites"; "LEARNING_SITES.ADD_SITE" = "Add a Learning Site"; -"LEARNING_SITES.SEARCH_PLACEHOLDER" = "Search for your school"; +"LEARNING_SITES.SEARCH_PLACEHOLDER" = "University of Arts & Sciences"; "LEARNING_SITES.EMPTY_SEARCH" = "No schools match your search"; "LEARNING_SITES.LOG_OUT_TITLE" = "Log Out"; "LEARNING_SITES.LOG_OUT_MESSAGE" = "Are you sure you want to log out of %@?"; diff --git a/Core/Core/Configuration/CSSInjector.swift b/Core/Core/Configuration/CSSInjector.swift index 8e4625c59..551912852 100644 --- a/Core/Core/Configuration/CSSInjector.swift +++ b/Core/Core/Configuration/CSSInjector.swift @@ -11,10 +11,17 @@ import Theme public class CSSInjector { - public let baseURL: URL + /// Read live via `config.baseURL`, not cached -- `config` is `InstanceAwareConfig` in + /// production, so its `.baseURL` tracks whichever instance is selected. Caching it here + /// (this is a DI singleton, resolved once) rewrote course/comment HTML's relative + /// media/honor/privacy links against whatever instance was selected when CSSInjector was + /// first resolved -- stale forever after any later switch, same bug already found and + /// fixed in RequestInterceptor and Connectivity. + private let config: ConfigProtocol + public var baseURL: URL { config.baseURL } public init(config: ConfigProtocol) { - self.baseURL = config.baseURL + self.config = config } public enum CssType { diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 425443112..2581353ac 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -28,7 +28,14 @@ public protocol ConnectivityProtocol: Sendable { public class Connectivity: ConnectivityProtocol { private let networkManager = NetworkReachabilityManager() - private let verificationURL: URL + /// Read live in `verifyInternet()`, not captured once -- `config` is `InstanceAwareConfig` + /// in production, so `.baseURL` tracks whichever instance is selected. Caching it at init + /// (the way `API`'s own baseURL is frozen at DI registration) meant this kept pinging + /// whatever host was selected when `Connectivity` first got resolved -- usually the app's + /// placeholder default, since this is a widely-depended-on singleton resolved early. Every + /// later verification against that unreachable placeholder reported "offline" regardless of + /// actual connectivity, even right after a successful login to a real instance. + private let config: ConfigProtocol private let verificationTimeout: TimeInterval private let cacheValidity: TimeInterval = 30 private let notReachableDelay: TimeInterval = 1.5 @@ -75,7 +82,7 @@ public class Connectivity: ConnectivityProtocol { config: ConfigProtocol, timeout: TimeInterval = 15 ) { - self.verificationURL = config.baseURL + self.config = config self.verificationTimeout = timeout networkManager?.startListening(onQueue: .global()) { [weak self] status in @@ -123,7 +130,7 @@ public class Connectivity: ConnectivityProtocol { } private func verifyInternet() async -> Bool { - var request = URLRequest(url: verificationURL) + var request = URLRequest(url: config.baseURL) request.httpMethod = "HEAD" request.timeoutInterval = verificationTimeout do { diff --git a/Core/Core/Configuration/InstanceSessionManager.swift b/Core/Core/Configuration/InstanceSessionManager.swift index 84bfd51d8..d7010c59b 100644 --- a/Core/Core/Configuration/InstanceSessionManager.swift +++ b/Core/Core/Configuration/InstanceSessionManager.swift @@ -9,6 +9,13 @@ import Foundation import SwiftUI import Theme +extension Notification.Name { + /// Posted whenever `applyThemeForCurrentInstance()` updates `Theme.Colors`/`Theme.UIColors`. + /// `UIWindow.tintColor` is a one-time snapshot, not a live binding to those statics -- the + /// app layer observes this to refresh it. See AppDelegate. + public static let accentColorDidChange = Notification.Name("org.openedx.core.accentColorDidChange") +} + /// @mockable public protocol InstanceSessionManagerProtocol: Sendable { /// Switches the active instance without tearing down the outgoing one's session. @@ -90,20 +97,38 @@ public final class InstanceSessionManager: InstanceSessionManagerProtocol { // defaults to it). Theme.Colors.update() Theme.UIColors.update() + Self.postAccentColorDidChange() return } Theme.Colors.update(accentColor: Color(uiColor: accentColor)) Theme.UIColors.update(accentColor: accentColor) + Self.postAccentColorDidChange() + } + + /// This type isn't actor-isolated, so `applyThemeForCurrentInstance()` can run off the + /// main thread (e.g. called via `await` from a nonisolated async context). `post(...)` + /// delivers to every observer SYNCHRONOUSLY on the posting thread, and AppDelegate's + /// observer touches `UIWindow.tintColor` -- touching UIKit off-main crashed + /// (EXC_BREAKPOINT) the moment an instance was selected. Always post from main. + private static func postAccentColorDidChange() { + if Thread.isMainThread { + NotificationCenter.default.post(name: .accentColorDidChange, object: nil) + } else { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .accentColorDidChange, object: nil) + } + } } /// A single UIColor that resolves to the instance's light/dark "accent_color" hex per the /// active trait collection -- SwiftUI's `Color(uiColor:)` stays adaptive through this. + /// Falls back to the instance's flat `color` field when THEME.LIGHT.accent_color isn't set, + /// per `Instance.themeColors`'s own doc comment ("nil derives the palette from color"). private static func resolveAccentColor(for instance: Instance?) -> UIColor? { - guard let lightHex = instance?.themeColors?.light["accent_color"], - let light = UIColor(hex: lightHex) else { - return nil - } - let dark = instance?.themeColors?.dark["accent_color"].flatMap { UIColor(hex: $0) } ?? light + guard let instance else { return nil } + let lightHex = instance.themeColors?.light["accent_color"] ?? instance.color + guard let light = UIColor(hex: lightHex) else { return nil } + let dark = instance.themeColors?.dark["accent_color"].flatMap { UIColor(hex: $0) } ?? light return UIColor { $0.userInterfaceStyle == .dark ? dark : light } } } diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index 21286e861..48aa2e0c4 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -25,10 +25,19 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { private let storage: CoreStorage private let instanceStore: InstanceProvider + /// `config.baseURL` is itself instance-aware (`InstanceAwareConfig`) and moves on every + /// switch. `API`'s own baseURL is a one-time snapshot taken at DI registration + /// (NetworkAssembly) -- captured here too, at the same moment (same resolution chain, no + /// instance switch can land in between), so `rewriteHostIfNeeded` keeps comparing against + /// what requests are actually still built with, not against a value that's since moved on + /// to whatever instance is currently selected. + private let defaultHost: String? + public init(config: ConfigProtocol, storage: CoreStorage, instanceStore: InstanceProvider) { self.config = config self.storage = storage self.instanceStore = instanceStore + self.defaultHost = config.baseURL.host } private let lock = NSLock() @@ -85,7 +94,7 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { let requestURL = request.url, var components = URLComponents(url: requestURL, resolvingAgainstBaseURL: false), let targetComponents = URLComponents(url: instance.baseURL, resolvingAgainstBaseURL: false), - requestURL.host == config.baseURL.host + requestURL.host == defaultHost else { // No instance selected, the instance host can't be parsed, or this request // wasn't built against the app's default base URL to begin with. diff --git a/Core/Core/View/Base/InstanceThemedImage.swift b/Core/Core/View/Base/InstanceThemedImage.swift index a68524834..9a5a3577f 100644 --- a/Core/Core/View/Base/InstanceThemedImage.swift +++ b/Core/Core/View/Base/InstanceThemedImage.swift @@ -6,30 +6,62 @@ // import SwiftUI +import UIKit + +/// Session-lifetime decoded-image cache so a logo already seen doesn't flash fallback -> +/// branded again on every reappearance (list scroll, revisiting a screen). `URLCache` already +/// avoids the network re-fetch; this avoids the re-decode and, more importantly, the placeholder +/// frame `AsyncImage` always renders first even for a cached response. `NSCache` is thread-safe +/// at runtime (Apple's own docs), but isn't `Sendable`, so the compiler can't verify that -- +/// `nonisolated(unsafe)` is the same escape hatch Theme.swift already uses for its static colors. +private enum InstanceImageCache { + private nonisolated(unsafe) static let cache = NSCache() + + static func image(for key: String) -> UIImage? { + cache.object(forKey: key as NSString) + } + + static func store(_ image: UIImage, for key: String) { + cache.setObject(image, forKey: key as NSString) + } +} /// Resolves an `Instance`-provided image source (`logoURLString`/`headerBackgroundURLString`) -/// to a displayable image: a remote URL loads via `AsyncImage`; a bundled asset name (only -/// `logoURLString` allows this) resolves via `UIImage(named:)`; anything else -- `nil`, an -/// unparsable URL, or a missing asset -- falls back to `fallback`. First use of `AsyncImage` -/// in this codebase. Callers chain their own sizing/`aspectRatio` modifiers, same as `Image`. +/// to a displayable image: a remote URL loads via `URLSession` (backed by `URLCache` on disk, +/// and `InstanceImageCache` in memory for the rest of the session -- see above); a bundled +/// asset name (only `logoURLString` allows this) resolves via `UIImage(named:)`; anything else +/// -- `nil`, an unparsable URL, or a missing asset -- falls back to `fallback`. Callers chain +/// their own sizing/`aspectRatio` modifiers, same as `Image`. public struct InstanceThemedImage: View { private let source: String? private let allowsBundledAsset: Bool private let fallback: Image + @State private var loadedImage: UIImage? + public init(source: String?, allowsBundledAsset: Bool = false, fallback: Image) { self.source = source self.allowsBundledAsset = allowsBundledAsset self.fallback = fallback + _loadedImage = State(initialValue: source.flatMap(Self.cachedImage)) } public var body: some View { if let source, let url = URL(string: source), let scheme = url.scheme, scheme.hasPrefix("http") { - AsyncImage(url: url) { image in - image.resizable() - } placeholder: { - fallback.resizable() + Group { + if let loadedImage { + Image(uiImage: loadedImage).resizable() + } else { + fallback.resizable() + } + } + .task(id: source) { + guard loadedImage == nil else { return } + guard let (data, _) = try? await URLSession.shared.data(from: url), + let uiImage = UIImage(data: data) else { return } + InstanceImageCache.store(uiImage, for: source) + loadedImage = uiImage } } else if allowsBundledAsset, let source, let uiImage = UIImage(named: source) { Image(uiImage: uiImage).resizable() @@ -37,4 +69,19 @@ public struct InstanceThemedImage: View { fallback.resizable() } } + + /// Memory cache first (this session), then `URLCache`'s disk cache (a prior launch) -- + /// checked synchronously so a previously-seen logo never flashes fallback first. + private static func cachedImage(for source: String) -> UIImage? { + if let cached = InstanceImageCache.image(for: source) { + return cached + } + guard let url = URL(string: source), + let data = URLCache.shared.cachedResponse(for: URLRequest(url: url))?.data, + let uiImage = UIImage(data: data) else { + return nil + } + InstanceImageCache.store(uiImage, for: source) + return uiImage + } } diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index ec441dc33..4cf59631d 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -15,6 +15,9 @@ public enum LogistrationSourceScreen: Equatable, Sendable { case discovery case courseDetail(String, String) case programDetails(String) + /// Reached via the Learning Sites picker (launch or Settings) -- the sign-in screen's + /// back button is valid here even when `startupScreenEnabled` is off. + case learningSites public var value: String? { return String(describing: self).components(separatedBy: "(").first diff --git a/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift b/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift index 1e27bf3bd..8ab3fbbb8 100644 --- a/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift +++ b/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift @@ -101,4 +101,25 @@ final class InstanceSessionManagerTests: XCTestCase { XCTAssertEqual(Theme.UIColors.accentColor.cgColor, ThemeAssets.accentColor.color.cgColor) } + + // A plain COLOR field (no nested THEME.LIGHT/DARK) still applies -- most instances don't + // configure the full light/dark palette. + func testSwitchActiveInstance_WithOnlyFlatColor_StillAppliesAccentColor() async { + let branded = Instance(dictionary: [ + "NAME": "Branded", "OAUTH_CLIENT_ID": "branded-id", "API_HOST_URL": "https://branded.example.com", + "COLOR": "#445566" + ])! + + await sut.switchActiveInstance(to: branded) + + XCTAssertEqual(Theme.UIColors.accentColor.cgColor, UIColor(hex: "#445566")!.cgColor) + } + + // AppDelegate refreshes window.tintColor (a one-time snapshot, not live-bound to + // Theme.UIColors.accentColor) off this notification -- it has to actually fire. + func testApplyThemeForCurrentInstance_PostsAccentColorDidChange() async { + let expectation = expectation(forNotification: .accentColorDidChange, object: nil) + await sut.switchActiveInstance(to: instanceA) + await fulfillment(of: [expectation], timeout: 1) + } } diff --git a/Core/CoreTests/RequestInterceptorTests.swift b/Core/CoreTests/RequestInterceptorTests.swift index 880817543..38324bb02 100644 --- a/Core/CoreTests/RequestInterceptorTests.swift +++ b/Core/CoreTests/RequestInterceptorTests.swift @@ -68,4 +68,26 @@ final class RequestInterceptorTests: XCTestCase { XCTAssertEqual(adapted.url?.host, "sso.thirdparty.com") } + + // Reproduces the real bug: in production `config` is `InstanceAwareConfig`, so + // `config.baseURL` itself tracks whichever instance is selected. `API`'s own baseURL + // never moves after DI registration, so requests keep being built against the original + // default -- if the interceptor compared against the live `config.baseURL` instead of a + // snapshot taken at init, this stops matching the moment any instance is selected, and + // rewriteHostIfNeeded silently stops firing. + func test_adapt_afterConfigBaseURLDrifts_stillRewritesRequestBuiltAgainstOriginalDefault() throws { + let config = makeConfig() + let instance = Instance.mock(baseURL: URL(string: "https://acme.example.com")!) + let instanceStore = InstanceProviderMock(currentInstance: instance) + let interceptor = Core.RequestInterceptor( + config: config, storage: CoreStorageMock(), instanceStore: instanceStore + ) + + // Simulate InstanceAwareConfig.baseURL drifting to the selected instance. + config.baseURL = instance.baseURL + + let adapted = try adapt(interceptor, url: "https://app.example.com/api/v1/courses") + + XCTAssertEqual(adapted.url?.host, "acme.example.com") + } } diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 605e2696e..256233de9 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -101,6 +101,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { object: nil ) + // window.tintColor isn't live-bound to Theme.UIColors.accentColor -- it's a one-time + // snapshot, so anything relying on the inherited tint (nav bars, back buttons, bar + // button items) needs this to pick up a later instance switch/logout. + NotificationCenter.default.addObserver( + self, + selector: #selector(accentColorDidChange), + name: .accentColorDidChange, + object: nil + ) + return true } @@ -197,6 +207,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { @objc private func didUserAuthorize() { Container.shared.resolve(PushNotificationsManager.self)?.synchronizeToken() } + + @objc private func accentColorDidChange() { + window?.tintColor = Theme.UIColors.accentColor + } @objc func didUserLogout(_ notification: Notification) { guard Date().timeIntervalSince1970 - lastForceLogoutTime > 5 else { diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 098a4e358..0fcc35907 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -249,12 +249,11 @@ class AppAssembly: Assembly { }.inObjectScope(.container) container.register(PipManagerProtocol.self) { @MainActor r in - let config = r.resolve(ConfigProtocol.self)! - return PipManager( + PipManager( router: r.resolve(Router.self)!, discoveryInteractor: r.resolve(DiscoveryInteractorProtocol.self)!, courseInteractor: r.resolve(CourseInteractorProtocol.self)!, - courseDropDownNavigationEnabled: config.uiComponents.courseDropDownNavigationEnabled + config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 113ecb3e6..79c0690a3 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -17,7 +17,11 @@ public final class PipManager: PipManagerProtocol { let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router - let courseDropDownNavigationEnabled: Bool + /// Read live via `config.uiComponents`, not cached -- `config` is `InstanceAwareConfig` in + /// production, so this tracks whichever instance is selected. Same DI-singleton-captures-a- + /// stale-value bug already found and fixed in CSSInjector/Connectivity/RequestInterceptor. + let config: ConfigProtocol + var courseDropDownNavigationEnabled: Bool { config.uiComponents.courseDropDownNavigationEnabled } public var isPipActive: Bool { controllerHolder != nil } @@ -30,12 +34,12 @@ public final class PipManager: PipManagerProtocol { router: Router, discoveryInteractor: DiscoveryInteractorProtocol, courseInteractor: CourseInteractorProtocol, - courseDropDownNavigationEnabled: Bool + config: ConfigProtocol ) { self.discoveryInteractor = discoveryInteractor self.courseInteractor = courseInteractor self.router = router - self.courseDropDownNavigationEnabled = courseDropDownNavigationEnabled + self.config = config } public func holder( From 869b7819d7c10049a0e542c5c3785359cf7fa399 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:30:11 +0000 Subject: [PATCH 4/5] feat: redesign Learning Sites picker, fix session/theme bugs - redesign Settings / Learning Sites to match the Figma design - fix: stale offline banner staying up after a real login - fix: reinstalled app showing old sessions as still signed in - fix: current site stuck selected after an abrupt relaunch - apply accent color to buttons and icons, not just links --- .../LearningSites/LearningSitesView.swift | 80 ++++++--- .../LearningSitesViewModel.swift | 20 ++- .../Authorization/SwiftGen/Strings.swift | 2 + .../en.lproj/Localizable.strings | 1 + .../LearningSitesViewModelTests.swift | 32 +++- .../Configuration/Config/InstanceStore.swift | 5 + Core/Core/Configuration/Connectivity.swift | 16 ++ .../InstanceSessionManager.swift | 10 +- .../Content/Elements/VideoThumbnailView.swift | 1 + OpenEdX/AppDelegate.swift | 26 +++ OpenEdX/DI/ScreenAssembly.swift | 1 + OpenEdX/Data/AppStorage.swift | 8 + OpenEdX/Info.plist | 4 - OpenEdX/RouteController.swift | 8 +- OpenEdX/Router.swift | 9 +- .../Presentation/Settings/SettingsView.swift | 159 ++++++++++-------- .../Settings/SettingsViewModel.swift | 6 + .../Settings/VideoQualityView.swift | 1 + .../Settings/VideoSettingsView.swift | 1 + Profile/Profile/SwiftGen/Strings.swift | 6 +- Profile/Profile/en.lproj/Localizable.strings | 1 + .../Settings/SettingsViewModelTests.swift | 6 + Theme/Theme/Theme.swift | 2 + 23 files changed, 291 insertions(+), 114 deletions(-) diff --git a/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift b/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift index d3d60bb77..2bfe91ad6 100644 --- a/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift +++ b/Authorization/Authorization/Presentation/LearningSites/LearningSitesView.swift @@ -31,22 +31,38 @@ public struct LearningSitesView: View { VStack(alignment: .center) { if viewModel.isSwitching { - ZStack { - HStack { - Text(AuthLocalization.LearningSites.title) - .titleSettings(color: Theme.Colors.loginNavigationText) - .accessibilityIdentifier("learning_sites_title_text") + // Fixed spacing, not a Spacer -- an unbounded Spacer here would expand + // to fill whatever room the ScrollView below doesn't claim. + VStack(alignment: .leading, spacing: 12) { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { viewModel.router.back() } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 16) + .accessibilityIdentifier("back_button") + + // The site being switched FROM. + if let current = viewModel.currentInstance { + HStack(spacing: 12) { + InstanceThemedImage( + source: current.logoURLString, + allowsBundledAsset: true, + fallback: ThemeAssets.appLogo.swiftUIImage + ) + .aspectRatio(contentMode: .fit) + .frame(width: 56, height: 56) + + Text(current.name) + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.loginNavigationText) + .accessibilityIdentifier("current_site_header_name") + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 24) } - VStack { - BackNavigationButton( - color: Theme.Colors.loginNavigationText, - action: { viewModel.router.back() } - ) - .backViewStyle() - .padding(.leading, isHorizontal ? 48 : 0) - .accessibilityIdentifier("back_button") - }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) } + .frame(maxWidth: .infinity, alignment: .leading) } else { ThemeAssets.appLogo.swiftUIImage .resizable() @@ -59,7 +75,19 @@ public struct LearningSitesView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { - if !viewModel.isSwitching { + if viewModel.isSwitching { + VStack(alignment: .leading, spacing: 4) { + Text(AuthLocalization.LearningSites.title) + .font(Theme.Fonts.displaySmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("learning_sites_title_text") + + Text(AuthLocalization.LearningSites.subtitle) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("learning_sites_subtitle_text") + } + } else { VStack(alignment: .leading, spacing: 4) { Text(AuthLocalization.LearningSites.getStarted) .font(Theme.Fonts.displaySmall) @@ -73,16 +101,24 @@ public struct LearningSitesView: View { } } - if !viewModel.currentLearningSites.isEmpty { + if let current = viewModel.currentInstance { sitesSection( title: AuthLocalization.LearningSites.currentSite, - sites: viewModel.currentLearningSites, + sites: [current], + showsLogOut: true + ) + } + + if !viewModel.otherLearningSites.isEmpty { + sitesSection( + title: AuthLocalization.LearningSites.moreSites, + sites: viewModel.otherLearningSites, showsLogOut: true ) } sitesSection( - title: AuthLocalization.LearningSites.searchTitle, + title: AuthLocalization.LearningSites.addSite, sites: viewModel.addableSites, showsLogOut: false, searchField: true @@ -170,12 +206,12 @@ public struct LearningSitesView: View { .frame(width: 50, height: 50) VStack(alignment: .leading, spacing: 2) { - Text(instance.name) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textPrimary) Text(instance.baseURL.host ?? instance.baseURL.absoluteString) .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.textSecondary) + Text(instance.name) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) } Spacer() @@ -188,7 +224,7 @@ public struct LearningSitesView: View { .accessibilityIdentifier("log_out_site_button") } } - .frame(height: 60) + .frame(minHeight: 60) .contentShape(Rectangle()) .onTapGesture { Task { await viewModel.select(instance) } diff --git a/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift b/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift index 38d87f40a..b4e489dd8 100644 --- a/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift +++ b/Authorization/Authorization/Presentation/LearningSites/LearningSitesViewModel.swift @@ -20,8 +20,11 @@ import Core /// at first launch, where this is the root screen with nothing logged into yet. let isSwitching: Bool - /// Catalog instances the user is already logged into. - private(set) var currentLearningSites: [Instance] = [] + /// The active instance, shown under "Current Learning Site". `nil` before first login. + private(set) var currentInstance: Instance? + + /// Signed-in instances other than the active one, shown under "More Learning Sites". + private(set) var otherLearningSites: [Instance] = [] /// Catalog instances not yet logged into, filtered by `searchText`. private(set) var addableSites: [Instance] = [] @@ -57,7 +60,8 @@ import Core catalog = instanceStore.instancesConfig.instances.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - currentLearningSites = catalog.filter(hasSession) + currentInstance = instanceStore.currentInstance + otherLearningSites = catalog.filter { hasSession($0) && $0.key != currentInstance?.key } updateAddableSites() } @@ -89,8 +93,16 @@ import Core /// clears that instance's stored session -- see `InstanceSessionManagerProtocol.logout`. /// The confirm dialog is presented by the view directly (`router.presentAlert`), same /// pattern as Settings' own Log Out button -- no pending-state needed here. + /// + /// Logging out the active instance pops back to a fresh Learning Sites screen, same as + /// Settings' Log Out; a non-active instance's session just ends silently in place. func logOut(_ instance: Instance) async { + let wasCurrent = instanceStore.currentInstance?.key == instance.key await sessionManager.logout(instance) - refresh() + if wasCurrent { + router.showStartupScreen() + } else { + refresh() + } } } diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index f43f26f99..3b159424a 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -77,6 +77,8 @@ public enum AuthLocalization { public static let searchPlaceholder = AuthLocalization.tr("Localizable", "LEARNING_SITES.SEARCH_PLACEHOLDER", fallback: "University of Arts & Sciences") /// Search public static let searchTitle = AuthLocalization.tr("Localizable", "LEARNING_SITES.SEARCH_TITLE", fallback: "Search") + /// Select or add a learning site + public static let subtitle = AuthLocalization.tr("Localizable", "LEARNING_SITES.SUBTITLE", fallback: "Select or add a learning site") /// Learning Sites public static let title = AuthLocalization.tr("Localizable", "LEARNING_SITES.TITLE", fallback: "Learning Sites") /// Welcome! Select your learning site diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index cf7483c6b..6724ae514 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -62,6 +62,7 @@ accordance with the [Privacy Policy.](%@)"; "LEARNING_SITES.GET_STARTED" = "Get Started"; "LEARNING_SITES.WELCOME_SUBTITLE" = "Welcome! Select your learning site"; "LEARNING_SITES.SEARCH_TITLE" = "Search"; +"LEARNING_SITES.SUBTITLE" = "Select or add a learning site"; "LEARNING_SITES.CURRENT_SITE" = "Current Learning Site"; "LEARNING_SITES.MORE_SITES" = "More Learning Sites"; "LEARNING_SITES.ADD_SITE" = "Add a Learning Site"; diff --git a/Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift index c2c8c2217..b39a24b46 100644 --- a/Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/LearningSites/LearningSitesViewModelTests.swift @@ -26,7 +26,7 @@ final class LearningSitesViewModelTests: XCTestCase { return InstanceStore(userDefaults: freshUserDefaults(name), instancesConfig: { catalog }) } - func test_currentLearningSites_reflectsHasSession() { + func test_otherLearningSites_reflectsHasSessionWhenNotCurrent() { let storage = CoreStorageMock() storage.sessionInstanceKeys = ["acme"] let viewModel = LearningSitesViewModel( @@ -37,7 +37,9 @@ final class LearningSitesViewModelTests: XCTestCase { isSwitching: true ) - XCTAssertEqual(viewModel.currentLearningSites.map(\.key), ["acme"]) + // Nothing was `select`-ed on the store, so "acme" is signed in but not current. + XCTAssertNil(viewModel.currentInstance) + XCTAssertEqual(viewModel.otherLearningSites.map(\.key), ["acme"]) XCTAssertEqual(viewModel.addableSites.map(\.key), ["beta"]) } @@ -108,7 +110,7 @@ final class LearningSitesViewModelTests: XCTestCase { isSwitching: true ) let acme = store.instance(withKey: "acme")! - XCTAssertEqual(viewModel.currentLearningSites.map(\.key), ["acme"]) + XCTAssertEqual(viewModel.otherLearningSites.map(\.key), ["acme"]) // The mock session manager doesn't touch `storage` itself -- mirror what the real // InstanceSessionManager does so `refresh()` sees the logout reflected. @@ -116,6 +118,28 @@ final class LearningSitesViewModelTests: XCTestCase { await viewModel.logOut(acme) XCTAssertEqual(sessionManager.logoutCallCount, 1) - XCTAssertTrue(viewModel.currentLearningSites.isEmpty) + XCTAssertTrue(viewModel.otherLearningSites.isEmpty) + } + + func test_logOut_ofCurrentInstance_showsStartupScreenInsteadOfRefreshing() async { + let router = AuthorizationRouterMock() + let sessionManager = InstanceSessionManagerProtocolMock() + let storage = CoreStorageMock() + storage.sessionInstanceKeys = ["acme"] + let store = makeStore(#function) + let acme = store.instance(withKey: "acme")! + store.select(acme) + let viewModel = LearningSitesViewModel( + instanceStore: store, + storage: storage, + sessionManager: sessionManager, + router: router, + isSwitching: true + ) + + await viewModel.logOut(acme) + + XCTAssertEqual(sessionManager.logoutCallCount, 1) + XCTAssertEqual(router.showStartupScreenCallCount, 1) } } diff --git a/Core/Core/Configuration/Config/InstanceStore.swift b/Core/Core/Configuration/Config/InstanceStore.swift index 13bfdd1a1..1adb383ee 100644 --- a/Core/Core/Configuration/Config/InstanceStore.swift +++ b/Core/Core/Configuration/Config/InstanceStore.swift @@ -33,6 +33,11 @@ public final class InstanceStore: InstanceProvider, @unchecked Sendable { return _remoteInstancesConfig ?? localInstancesConfig() } + /// Looks up an instance in the current catalog by its `key`. + public func instance(withKey key: String) -> Instance? { + instancesConfig.instance(withKey: key) + } + public init( userDefaults: UserDefaults = .standard, instancesConfig: @escaping () -> InstancesConfig = { InstancesConfig() } diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 2581353ac..f43b5ab78 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -43,6 +43,9 @@ public class Connectivity: ConnectivityProtocol { private var lastVerificationDate: TimeInterval? private var lastVerificationResult: Bool = true private var notReachableTask: Task? + /// Not removed in `deinit` -- this is a `.container`-scope singleton, alive for the + /// app's whole session. + private var instanceObserver: NSObjectProtocol? // MARK: - Observable property (new way) public private(set) var internetState: InternetState? { @@ -85,6 +88,19 @@ public class Connectivity: ConnectivityProtocol { self.config = config self.verificationTimeout = timeout + // The first verification often runs before any instance is selected, hits the + // placeholder host, and that "offline" verdict then sits cached -- causing a false + // offline banner right after login. Re-verify whenever the selected instance changes. + instanceObserver = NotificationCenter.default.addObserver( + forName: .instanceDidChange, + object: nil, + queue: nil + ) { [weak self] _ in + Task { @MainActor in + await self?.performVerification() + } + } + networkManager?.startListening(onQueue: .global()) { [weak self] status in guard let self = self else { return } Task { @MainActor in diff --git a/Core/Core/Configuration/InstanceSessionManager.swift b/Core/Core/Configuration/InstanceSessionManager.swift index d7010c59b..27f95f1bb 100644 --- a/Core/Core/Configuration/InstanceSessionManager.swift +++ b/Core/Core/Configuration/InstanceSessionManager.swift @@ -100,8 +100,14 @@ public final class InstanceSessionManager: InstanceSessionManagerProtocol { Self.postAccentColorDidChange() return } - Theme.Colors.update(accentColor: Color(uiColor: accentColor)) - Theme.UIColors.update(accentColor: accentColor) + // accentXColor/accentButtonColor share accentColor's value -- no separate config + // key exists for them. + Theme.Colors.update( + accentColor: Color(uiColor: accentColor), + accentXColor: Color(uiColor: accentColor), + accentButtonColor: Color(uiColor: accentColor) + ) + Theme.UIColors.update(accentColor: accentColor, accentXColor: accentColor) Self.postAccentColorDidChange() } diff --git a/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift b/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift index 9964e1920..1afe0e63f 100644 --- a/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift +++ b/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift @@ -92,6 +92,7 @@ struct VideoThumbnailView: View { thumbnailHeight: CGFloat = 108, isCurrentVideo: Bool = false ) { + self.type = type self.thumbnailData = thumbnailData self.thumbnailImage = thumbnailImage self.isGeneratingThumbnail = isGeneratingThumbnail diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 256233de9..53a74c862 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -188,6 +188,32 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // A persisted selection is restored inside updateInstancesConfig() above without // going through switchActiveInstance(to:), so nothing else applies its theme colors. Container.shared.resolve(InstanceSessionManagerProtocol.self)?.applyThemeForCurrentInstance() + + reconcileStaleKeychainSessions(against: instanceStore) + } + + /// Keychain survives an app delete + reinstall; UserDefaults doesn't. Left alone, a + /// reinstall shows every previously-logged-in instance as still signed in (Keychain has + /// a token) with no matching UserDefaults `user` record -- tapping one opens Home with a + /// dead token. A real login/logout always sets/clears both together, so that mismatch + /// only means a reinstall; reconcile it for every instance on every launch. + /// + /// Must run after `updateInstancesConfig(_:)` above, once the real catalog (not the + /// bundled placeholder) is loaded. + private func reconcileStaleKeychainSessions(against instanceStore: InstanceStore) { + guard let appStorage = Container.shared.resolve(AppStorage.self) else { return } + for instance in instanceStore.instancesConfig.instances + where appStorage.hasSession(forInstanceKey: instance.key) + && !appStorage.hasUserRecord(forInstanceKey: instance.key) { + appStorage.clearSession(forInstanceKey: instance.key) + } + + // UserDefaults writes aren't guaranteed to hit disk before an abrupt process kill + // (e.g. Xcode's Stop button), so a logout right before that can leave the selected- + // instance pointer stuck on an instance with no session. Deselect it here too. + if let current = instanceStore.currentInstance, !appStorage.hasSession(forInstanceKey: current.key) { + instanceStore.select(nil) + } } private func initDI() { diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 330f92b73..0783ca8b0 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -295,6 +295,7 @@ class ScreenAssembly: Assembly { SettingsViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, sessionManager: r.resolve(InstanceSessionManagerProtocol.self)!, + instanceStore: r.resolve(InstanceStore.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, coreAnalytics: r.resolve(CoreAnalytics.self)!, diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index e70efe23a..6ab2e884d 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -417,6 +417,14 @@ public final class AppStorage: CoreStorage, keychain.delete("\(key).\(KEY_REFRESH_TOKEN)") } + /// Whether a `user` record survives in UserDefaults for `key`'s instance -- the + /// UserDefaults counterpart to `hasSession(forInstanceKey:)`'s Keychain check, used to + /// detect Keychain outliving a UserDefaults wipe (reinstall). Not part of `CoreStorage`, + /// so it doesn't need mocking everywhere that protocol is. + public func hasUserRecord(forInstanceKey key: String) -> Bool { + userDefaults.data(forKey: "\(key).\(KEY_USER)") != nil + } + private let KEY_ACCESS_TOKEN = "accessToken" private let KEY_REFRESH_TOKEN = "refreshToken" private let KEY_PUSH_TOKEN = "pushToken" diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index e9bd32e58..8c9ee56ca 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -14,8 +14,6 @@ FirebaseCrashlyticsCollectionEnabled - ITSAppUsesNonExemptEncryption - LSApplicationQueriesSchemes googlegmail @@ -33,8 +31,6 @@ NSAllowsArbitraryLoadsInWebContent - NSCalendarsFullAccessUsageDescription - We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. UIAppFonts UIBackgroundModes diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 694cdca55..ed21f5ba5 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -53,10 +53,10 @@ class RouteController: UIViewController { } private func showStartupScreen() { - // A multi-instance catalog with nothing selected yet needs the site picker, not - // Startup/SignIn -- those assume a single (app-level) destination to sign into. - // A single-instance catalog never reaches here: InstanceStore auto-selects it. - if instanceStore.currentInstance == nil, instanceStore.instancesConfig.instances.count > 1 { + // No valid session exists at this point (see the check above), so for a + // multi-instance catalog that means the site picker, not Startup/SignIn. A + // single-instance catalog never reaches here -- InstanceStore auto-selects it. + if instanceStore.instancesConfig.instances.count > 1 { let controller = UIHostingController( rootView: LearningSitesView( viewModel: diContainer.resolve(LearningSitesViewModel.self, argument: false)! diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 4caa6f8ac..802fe1bd4 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -132,12 +132,11 @@ public class Router: AuthorizationRouter, } public func showStartupScreen() { - // Same branch as RouteController.showStartupScreen() -- a multi-instance catalog - // with nothing selected needs the site picker, not Startup/SignIn. Reached when a - // full logout (e.g. Settings' Log Out) clears the selection while other sites are - // still in the catalog. + // Same branch as RouteController.showStartupScreen() -- no valid session exists + // here, so for a multi-instance catalog that means the site picker, not + // Startup/SignIn. Also covers a full logout that clears the selection while other + // sites remain. if let instanceStore = Container.shared.resolve(InstanceStore.self), - instanceStore.currentInstance == nil, instanceStore.instancesConfig.instances.count > 1 { let view = LearningSitesView( viewModel: Container.shared.resolve(LearningSitesViewModel.self, argument: false)! diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index dc9bb7075..1236c9328 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -66,11 +66,10 @@ public struct SettingsView: View { .accessibilityIdentifier("progress_bar") } else { manageAccount - learningSites settings datesAndCalendar ProfileSupportInfoView(viewModel: viewModel) - logOutButton + currentLearningSite } } .frame( @@ -164,32 +163,6 @@ public struct SettingsView: View { ) } - // MARK: - Learning Sites - - @ViewBuilder - private var learningSites: some View { - VStack(alignment: .leading, spacing: 27) { - Button(action: { - viewModel.router.showLearningSites() - }, label: { - HStack { - Text(ProfileLocalization.learningSites) - .font(Theme.Fonts.titleMedium) - Spacer() - Image(systemName: "chevron.right") - .flipsForRightToLeftLayoutDirection(true) - } - }) - .accessibilityIdentifier("learning_sites_button") - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(ProfileLocalization.learningSites) - .cardStyle( - bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear - ) - } - // MARK: - Settings @ViewBuilder @@ -225,47 +198,98 @@ public struct SettingsView: View { ) } + // MARK: - Current Learning Site + + // One card: the site row (opens the site-switch screen) plus Log Out folded in below it. + @ViewBuilder + private var currentLearningSite: some View { + if let instance = viewModel.currentInstance { + Text(ProfileLocalization.currentLearningSite) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("current_learning_site_text") + .padding(.top, 12) + + VStack(alignment: .leading, spacing: 0) { + Button(action: { viewModel.router.showLearningSites() }, label: { + HStack(spacing: 12) { + InstanceThemedImage( + source: instance.logoURLString, + allowsBundledAsset: true, + fallback: ThemeAssets.appLogo.swiftUIImage + ) + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) + + VStack(alignment: .leading, spacing: 2) { + Text(instance.baseURL.host ?? instance.baseURL.absoluteString) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textSecondary) + Text(instance.name) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + } + + Spacer() + + Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) + .foregroundColor(Theme.Colors.textPrimary) + } + .frame(minHeight: 60) + // Balances cardStyle's top inset so the row centers between the card's + // top edge and the divider, instead of hugging the divider. + .padding(.bottom, 26) + }) + .accessibilityElement(children: .ignore) + .accessibilityLabel(instance.name) + .accessibilityIdentifier("current_learning_site_button") + + Divider() + + Button(action: { presentLogOutConfirm() }, label: { + HStack { + Text(ProfileLocalization.logout) + Spacer() + Image(systemName: "rectangle.portrait.and.arrow.right") + } + }) + .foregroundColor(Theme.Colors.alert) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.logout) + .accessibilityIdentifier("logout_button") + .padding(.top, 27) + } + .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) + .padding(.bottom, 60) + } + } + // MARK: - Log out - - private var logOutButton: some View { - VStack { - Button(action: { - viewModel.trackLogoutClickedClicked() - viewModel.router.presentView( - transitionStyle: .crossDissolve, - animated: true - ) { - AlertView( - alertTitle: ProfileLocalization.LogoutAlert.title, - alertMessage: ProfileLocalization.LogoutAlert.text, - positiveAction: CoreLocalization.Alert.accept, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - firstButtonTapped: { - viewModel.router.dismiss(animated: true) - Task { - await viewModel.logOut() - } - }, - type: .logOut - ) - } - }, label: { - HStack { - Text(ProfileLocalization.logout) - Spacer() - Image(systemName: "rectangle.portrait.and.arrow.right") - } - }) - .accessibilityElement(children: .ignore) - .accessibilityLabel(ProfileLocalization.logout) - .accessibilityIdentifier("logout_button") + + private func presentLogOutConfirm() { + viewModel.trackLogoutClickedClicked() + viewModel.router.presentView( + transitionStyle: .crossDissolve, + animated: true + ) { + AlertView( + alertTitle: ProfileLocalization.LogoutAlert.title, + alertMessage: ProfileLocalization.LogoutAlert.text, + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { + viewModel.router.dismiss(animated: true) + }, + firstButtonTapped: { + viewModel.router.dismiss(animated: true) + Task { + await viewModel.logOut() + } + }, + type: .logOut + ) } - .foregroundColor(Theme.Colors.alert) - .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) - .padding(.top, 24) - .padding(.bottom, 60) } } @@ -275,6 +299,7 @@ public struct SettingsView: View { let vm = SettingsViewModel( interactor: ProfileInteractor.mock, sessionManager: InstanceSessionManagerProtocolMock(), + instanceStore: InstanceStore(), router: router, analytics: ProfileAnalyticsPreview(), coreAnalytics: CoreAnalyticsMock(), diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 1a5b64f50..6e37282c3 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -70,6 +70,7 @@ public final class SettingsViewModel { private let interactor: ProfileInteractorProtocol private let sessionManager: InstanceSessionManagerProtocol + private let instanceStore: InstanceStore let router: ProfileRouter let analytics: ProfileAnalytics let coreAnalytics: CoreAnalytics @@ -77,10 +78,14 @@ public final class SettingsViewModel { let corePersistence: CorePersistenceProtocol let connectivity: ConnectivityProtocol private var coreStorage: CoreStorage + + /// The signed-in instance, shown as the "Current Learning Site" section in Settings. + var currentInstance: Instance? { instanceStore.currentInstance } public init( interactor: ProfileInteractorProtocol, sessionManager: InstanceSessionManagerProtocol, + instanceStore: InstanceStore, router: ProfileRouter, analytics: ProfileAnalytics, coreAnalytics: CoreAnalytics, @@ -91,6 +96,7 @@ public final class SettingsViewModel { ) { self.interactor = interactor self.sessionManager = sessionManager + self.instanceStore = instanceStore self.router = router self.analytics = analytics self.coreAnalytics = coreAnalytics diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index acbcb68bc..bb1c490c3 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -128,6 +128,7 @@ public struct VideoQualityView: View { let vm = SettingsViewModel( interactor: ProfileInteractor.mock, sessionManager: InstanceSessionManagerProtocolMock(), + instanceStore: InstanceStore(), router: router, analytics: ProfileAnalyticsPreview(), coreAnalytics: CoreAnalyticsMock(), diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index 18154bdeb..f390fc926 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -135,6 +135,7 @@ public struct VideoSettingsView: View { let vm = SettingsViewModel( interactor: ProfileInteractor.mock, sessionManager: InstanceSessionManagerProtocolMock(), + instanceStore: InstanceStore(), router: router, analytics: ProfileAnalyticsPreview(), coreAnalytics: CoreAnalyticsMock(), diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 686755c20..057be77e0 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -18,6 +18,8 @@ public enum ProfileLocalization { public static let contact = ProfileLocalization.tr("Localizable", "CONTACT", fallback: "Contact support") /// Cookie policy public static let cookiePolicy = ProfileLocalization.tr("Localizable", "COOKIE_POLICY", fallback: "Cookie policy") + /// Current Learning Site + public static let currentLearningSite = ProfileLocalization.tr("Localizable", "CURRENT_LEARNING_SITE", fallback: "Current Learning Site") /// Dates & Calendar public static let datesAndCalendar = ProfileLocalization.tr("Localizable", "DATES_AND_CALENDAR", fallback: "Dates & Calendar") /// Do not sell my personal information @@ -30,14 +32,14 @@ public enum ProfileLocalization { public static let fullProfile = ProfileLocalization.tr("Localizable", "FULL_PROFILE", fallback: "full profile") /// Profile info public static let info = ProfileLocalization.tr("Localizable", "INFO", fallback: "Profile info") + /// Learning Sites + public static let learningSites = ProfileLocalization.tr("Localizable", "LEARNING_SITES", fallback: "Learning Sites") /// limited profile public static let limitedProfile = ProfileLocalization.tr("Localizable", "LIMITED_PROFILE", fallback: "limited profile") /// Log out public static let logout = ProfileLocalization.tr("Localizable", "LOGOUT", fallback: "Log out") /// Manage Account public static let manageAccount = ProfileLocalization.tr("Localizable", "MANAGE_ACCOUNT", fallback: "Manage Account") - /// Learning Sites - public static let learningSites = ProfileLocalization.tr("Localizable", "LEARNING_SITES", fallback: "Learning Sites") /// Privacy policy public static let privacy = ProfileLocalization.tr("Localizable", "PRIVACY", fallback: "Privacy policy") /// Settings diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 2c35bb286..4e4fc7fc4 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -20,6 +20,7 @@ "TERMS" = "Terms of use"; "PRIVACY" = "Privacy policy"; "COOKIE_POLICY" = "Cookie policy"; +"CURRENT_LEARNING_SITE" = "Current Learning Site"; "DO_NOT_SELL_INFORMATION" = "Do not sell my personal information"; "FAQ_TITLE" = "View FAQ"; "MANAGE_ACCOUNT" = "Manage Account"; diff --git a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift index e9da91906..69ac29940 100644 --- a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift @@ -40,6 +40,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, sessionManager: sessionManager, + instanceStore: InstanceStore(), router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -80,6 +81,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, sessionManager: sessionManager, + instanceStore: InstanceStore(), router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -118,6 +120,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, sessionManager: sessionManager, + instanceStore: InstanceStore(), router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -156,6 +159,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, sessionManager: sessionManager, + instanceStore: InstanceStore(), router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -194,6 +198,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, sessionManager: sessionManager, + instanceStore: InstanceStore(), router: router, analytics: analytics, coreAnalytics: coreAnalytics, @@ -232,6 +237,7 @@ final class SettingsViewModelTests: XCTestCase { let viewModel = SettingsViewModel( interactor: interactor, sessionManager: sessionManager, + instanceStore: InstanceStore(), router: router, analytics: analytics, coreAnalytics: coreAnalytics, diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index f77af6ea9..7996c81d5 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -96,6 +96,7 @@ public struct Theme: Sendable { public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, accentXColor: Color = ThemeAssets.accentXColor.swiftUIColor, + accentButtonColor: Color = ThemeAssets.accentButtonColor.swiftUIColor, alert: Color = ThemeAssets.alert.swiftUIColor, avatarStroke: Color = ThemeAssets.avatarStroke.swiftUIColor, background: Color = ThemeAssets.background.swiftUIColor, @@ -144,6 +145,7 @@ public struct Theme: Sendable { ) { self.accentColor = accentColor self.accentXColor = accentXColor + self.accentButtonColor = accentButtonColor self.alert = alert self.avatarStroke = avatarStroke self.background = background From e0ff860f79e424c6a0e2e3add478e829a3d05c87 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:30:11 +0000 Subject: [PATCH 5/5] fix: instance-switching and launch reliability fixes - apply per-instance HEADER_BACKGROUND_URL to post-login screens - don't flash the offline banner on a cold-launch connectivity check - stop stale downloads showing as finished after a user/instance switch - hide single-instance UI when there's nothing to switch between --- .../Presentation/Login/SignInView.swift | 2 +- .../Presentation/Login/SignInViewModel.swift | 1 + .../Configuration/Config/InstanceStore.swift | 4 + .../Config/InstancesConfig.swift | 6 +- Core/Core/Configuration/Connectivity.swift | 9 ++ Core/Core/Network/DownloadManager.swift | 31 ++++++- .../View/Base/VideoDownloadQualityView.swift | 10 ++- OpenEdX.xcodeproj/project.pbxproj | 12 +++ OpenEdX/DI/ScreenAssembly.swift | 6 +- .../DatesAndCalendar/CoursesToSyncView.swift | 9 +- .../DatesAndCalendarView.swift | 9 +- .../DatesAndCalendarViewModel.swift | 6 +- .../Elements/NewCalendarView.swift | 3 +- .../SyncCalendarOptionsView.swift | 9 +- .../Settings/ManageAccountView.swift | 11 ++- .../Settings/ManageAccountViewModel.swift | 7 +- .../Presentation/Settings/SettingsView.swift | 87 ++++++++++--------- .../Settings/SettingsViewModel.swift | 1 + .../Settings/VideoQualityView.swift | 8 +- .../Settings/VideoSettingsView.swift | 8 +- .../DatesAndCalendarViewModelTests.swift | 24 +++-- 21 files changed, 182 insertions(+), 81 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index d2b75873a..057f548e1 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -66,7 +66,7 @@ public struct SignInView: View { ScrollView { VStack { VStack(alignment: .leading) { - if let instance = viewModel.currentInstance { + if let instance = viewModel.currentInstance, viewModel.hasMultipleInstances { selectedSiteSection(instance) .padding(.bottom, 20) .accessibilityIdentifier("selected_site_section") diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index ebc8f85dc..563708c90 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -49,6 +49,7 @@ import MSAL /// The instance being signed into, for per-instance branding. `nil` on a single-instance /// deployment, where the static default branding applies. var currentInstance: Instance? { instanceProvider.currentInstance } + var hasMultipleInstances: Bool { instanceProvider.hasMultipleInstances } public init( interactor: AuthInteractorProtocol, diff --git a/Core/Core/Configuration/Config/InstanceStore.swift b/Core/Core/Configuration/Config/InstanceStore.swift index 1adb383ee..4c70138f6 100644 --- a/Core/Core/Configuration/Config/InstanceStore.swift +++ b/Core/Core/Configuration/Config/InstanceStore.swift @@ -27,6 +27,10 @@ public final class InstanceStore: InstanceProvider, @unchecked Sendable { return _currentInstance } + public var hasMultipleInstances: Bool { + instancesConfig.instances.count > 1 + } + /// The catalog currently in effect: the remote one once set, else `localInstancesConfig`. public var instancesConfig: InstancesConfig { lock.lock(); defer { lock.unlock() } diff --git a/Core/Core/Configuration/Config/InstancesConfig.swift b/Core/Core/Configuration/Config/InstancesConfig.swift index 3967dbb70..b5fa57535 100644 --- a/Core/Core/Configuration/Config/InstancesConfig.swift +++ b/Core/Core/Configuration/Config/InstancesConfig.swift @@ -14,6 +14,8 @@ import OEXFoundation public protocol InstanceProvider: Sendable { /// `nil` when no instance is selected (e.g. single-instance deployment). var currentInstance: Instance? { get } + /// Whether the catalog has more than one instance to switch between. + var hasMultipleInstances: Bool { get } } // MARK: - Instance @@ -404,8 +406,10 @@ public extension Instance { public final class InstanceProviderMock: InstanceProvider, @unchecked Sendable { public var currentInstance: Instance? - public init(currentInstance: Instance? = .mock()) { + public var hasMultipleInstances: Bool + public init(currentInstance: Instance? = .mock(), hasMultipleInstances: Bool = true) { self.currentInstance = currentInstance + self.hasMultipleInstances = hasMultipleInstances } } #endif diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index f43b5ab78..fc98a392d 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -146,6 +146,15 @@ public class Connectivity: ConnectivityProtocol { } private func verifyInternet() async -> Bool { + if await headCheck() { return true } + // A single failed check -- especially the very first one, which can land right as + // the app cold-launches -- is often the network stack still warming up, not real + // offline. Retry once before trusting it and showing the offline banner. + try? await Task.sleep(nanoseconds: 800_000_000) + return await headCheck() + } + + private func headCheck() async -> Bool { var request = URLRequest(url: config.baseURL) request.httpMethod = "HEAD" request.timeoutInterval = verificationTimeout diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index bda8ed94b..7a9a14409 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -238,10 +238,15 @@ public actor DownloadManager: DownloadManagerProtocol, @unchecked Sendable { self.filePathProvider = filePathProvider if let userId = appStorage.user?.id { persistence.set(userId: userId) - Task { - await self.addObsevers() - await self.backgroundTask() - } + } + // DownloadManager is a container-scope singleton, first built at app launch + // (RouteController resolves it before any login screen shows) -- gating this + // behind an already-logged-in user meant it never ran for the app's whole + // life on a fresh launch, so the .userAuthorized/.instanceDidChange queue + // reset below never actually wired up. Always start observing. + Task { + await self.addObsevers() + await self.backgroundTask() } } @@ -260,6 +265,24 @@ public actor DownloadManager: DownloadManagerProtocol, @unchecked Sendable { } } .store(in: &cancellables) + + // queue caches CorePersistence rows in memory, keyed only by courseId/blockId + // (no userId/instanceKey). CorePersistence itself re-scopes correctly on every + // login/instance switch, but this cache doesn't -- so a stale queue from before + // the switch leaks "finished" entries into a different user/instance that + // happens to share the same course/block ids. Drop it so the next read refetches. + NotificationCenter.default.publisher(for: .userAuthorized) + .merge(with: NotificationCenter.default.publisher(for: .instanceDidChange)) + .sink { [weak self] _ in + Task { [weak self] in + await self?.resetQueue() + } + } + .store(in: &cancellables) + } + + private func resetQueue() { + queue = [] } nonisolated private func observeConnectivity() { diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 576e5d9da..b600bf98a 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -8,11 +8,13 @@ import SwiftUI import Kingfisher import Theme +import Swinject @Observable public final class VideoDownloadQualityViewModel { var didSelect: ((DownloadQuality) -> Void)? + var currentInstance: Instance? { Container.shared.resolve(InstanceStore.self)?.currentInstance } let downloadQuality = DownloadQuality.allCases var selectedDownloadQuality: DownloadQuality { @@ -58,9 +60,11 @@ public struct VideoDownloadQualityView: View { ZStack(alignment: .top) { if !isModal { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) + .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("auth_bg_image") diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 51bfc2b81..e12194dcb 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -716,7 +716,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -808,7 +810,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -906,7 +910,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -998,7 +1004,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -1150,7 +1158,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -1188,7 +1198,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 0783ca8b0..3b8865758 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -313,7 +313,8 @@ class ScreenAssembly: Assembly { profileStorage: r.resolve(ProfileStorage.self)!, persistence: r.resolve(ProfilePersistenceProtocol.self)!, calendarManager: r.resolve(CalendarManagerProtocol.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)! + connectivity: r.resolve(ConnectivityProtocol.self)!, + instanceStore: r.resolve(InstanceStore.self)! ) } .inObjectScope(.weak) @@ -324,7 +325,8 @@ class ScreenAssembly: Assembly { analytics: r.resolve(ProfileAnalytics.self)!, config: r.resolve(ConfigProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - interactor: r.resolve(ProfileInteractorProtocol.self)! + interactor: r.resolve(ProfileInteractorProtocol.self)!, + instanceStore: r.resolve(InstanceStore.self)! ) } diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index ba3524e12..21fbc6b43 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -23,8 +23,10 @@ public struct CoursesToSyncView: View { public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("title_bg_image") @@ -161,7 +163,8 @@ struct CoursesToSyncView_Previews: PreviewProvider { profileStorage: ProfileStoragePreview(), persistence: ProfilePersistencePreview(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity(config: ConfigMock()) + connectivity: Connectivity(config: ConfigMock()), + instanceStore: InstanceStore() ) return CoursesToSyncView(viewModel: vm) .previewDisplayName("Courses to Sync") diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index df173f16d..75fe171c6 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -25,8 +25,10 @@ public struct DatesAndCalendarView: View { public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("title_bg_image") @@ -188,7 +190,8 @@ struct DatesAndCalendarView_Previews: PreviewProvider { profileStorage: ProfileStoragePreview(), persistence: ProfilePersistencePreview(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity(config: ConfigMock()) + connectivity: Connectivity(config: ConfigMock()), + instanceStore: InstanceStore() ) DatesAndCalendarView(viewModel: vm) .loadFonts() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index 84f870571..97f55f4d5 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -78,8 +78,10 @@ public final class DatesAndCalendarViewModel { private var persistence: ProfilePersistenceProtocol private var calendarManager: CalendarManagerProtocol private var connectivity: ConnectivityProtocol + private let instanceStore: InstanceStore var calendarNameHint: String + var currentInstance: Instance? { instanceStore.currentInstance } public init( router: ProfileRouter, @@ -87,7 +89,8 @@ public final class DatesAndCalendarViewModel { profileStorage: ProfileStorage, persistence: ProfilePersistenceProtocol, calendarManager: CalendarManagerProtocol, - connectivity: ConnectivityProtocol + connectivity: ConnectivityProtocol, + instanceStore: InstanceStore ) { self.router = router self.interactor = interactor @@ -95,6 +98,7 @@ public final class DatesAndCalendarViewModel { self.persistence = persistence self.calendarManager = calendarManager self.connectivity = connectivity + self.instanceStore = instanceStore self.calendarNameHint = ProfileLocalization.Calendar.courseDates((Bundle.main.applicationName ?? "")) } diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift index 8ed0dfb23..ce86f16e2 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift @@ -167,7 +167,8 @@ struct NewCalendarView: View { profileStorage: ProfileStoragePreview(), persistence: ProfilePersistencePreview(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity(config: ConfigMock()) + connectivity: Connectivity(config: ConfigMock()), + instanceStore: InstanceStore() ), beginSyncingTapped: { }, diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index 619a30eb7..20dd31e92 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -25,8 +25,10 @@ public struct SyncCalendarOptionsView: View { public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("title_bg_image") @@ -274,7 +276,8 @@ struct SyncCalendarOptionsView_Previews: PreviewProvider { profileStorage: ProfileStoragePreview(), persistence: ProfilePersistencePreview(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity(config: ConfigMock()) + connectivity: Connectivity(config: ConfigMock()), + instanceStore: InstanceStore() ) SyncCalendarOptionsView(viewModel: vm) .loadFonts() diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift index c2f4f2c5f..39e36f36a 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountView.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -25,9 +25,11 @@ public struct ManageAccountView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) + .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("auth_bg_image") @@ -208,7 +210,8 @@ struct ManageAccountView_Previews: PreviewProvider { analytics: ProfileAnalyticsPreview(), config: configMock, connectivity: Connectivity(config: configMock), - interactor: ProfileInteractor.mock + interactor: ProfileInteractor.mock, + instanceStore: InstanceStore() ) ManageAccountView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift index 571c9e924..924ee5dcf 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift @@ -30,19 +30,24 @@ public final class ManageAccountViewModel { let config: ConfigProtocol let connectivity: ConnectivityProtocol private let interactor: ProfileInteractorProtocol + private let instanceStore: InstanceStore + + var currentInstance: Instance? { instanceStore.currentInstance } public init( router: ProfileRouter, analytics: ProfileAnalytics, config: ConfigProtocol, connectivity: ConnectivityProtocol, - interactor: ProfileInteractorProtocol + interactor: ProfileInteractorProtocol, + instanceStore: InstanceStore ) { self.router = router self.analytics = analytics self.config = config self.connectivity = connectivity self.interactor = interactor + self.instanceStore = instanceStore } @MainActor diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 1236c9328..1b558d319 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -25,9 +25,11 @@ public struct SettingsView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) + .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 50) .accessibilityIdentifier("auth_bg_image") @@ -204,49 +206,54 @@ public struct SettingsView: View { @ViewBuilder private var currentLearningSite: some View { if let instance = viewModel.currentInstance { - Text(ProfileLocalization.currentLearningSite) - .padding(.horizontal, 24) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textSecondary) - .accessibilityIdentifier("current_learning_site_text") - .padding(.top, 12) + // Single-instance deployments have nothing to switch between -- only Log Out applies. + if viewModel.hasMultipleInstances { + Text(ProfileLocalization.currentLearningSite) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("current_learning_site_text") + .padding(.top, 12) + } VStack(alignment: .leading, spacing: 0) { - Button(action: { viewModel.router.showLearningSites() }, label: { - HStack(spacing: 12) { - InstanceThemedImage( - source: instance.logoURLString, - allowsBundledAsset: true, - fallback: ThemeAssets.appLogo.swiftUIImage - ) - .aspectRatio(contentMode: .fit) - .frame(width: 50, height: 50) + if viewModel.hasMultipleInstances { + Button(action: { viewModel.router.showLearningSites() }, label: { + HStack(spacing: 12) { + InstanceThemedImage( + source: instance.logoURLString, + allowsBundledAsset: true, + fallback: ThemeAssets.appLogo.swiftUIImage + ) + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) - VStack(alignment: .leading, spacing: 2) { - Text(instance.baseURL.host ?? instance.baseURL.absoluteString) - .font(Theme.Fonts.labelMedium) - .foregroundColor(Theme.Colors.textSecondary) - Text(instance.name) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textPrimary) - } + VStack(alignment: .leading, spacing: 2) { + Text(instance.baseURL.host ?? instance.baseURL.absoluteString) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textSecondary) + Text(instance.name) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + } - Spacer() + Spacer() - Image(systemName: "chevron.right") - .flipsForRightToLeftLayoutDirection(true) - .foregroundColor(Theme.Colors.textPrimary) - } - .frame(minHeight: 60) - // Balances cardStyle's top inset so the row centers between the card's - // top edge and the divider, instead of hugging the divider. - .padding(.bottom, 26) - }) - .accessibilityElement(children: .ignore) - .accessibilityLabel(instance.name) - .accessibilityIdentifier("current_learning_site_button") + Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) + .foregroundColor(Theme.Colors.textPrimary) + } + .frame(minHeight: 60) + // Balances cardStyle's top inset so the row centers between the card's + // top edge and the divider, instead of hugging the divider. + .padding(.bottom, 26) + }) + .accessibilityElement(children: .ignore) + .accessibilityLabel(instance.name) + .accessibilityIdentifier("current_learning_site_button") - Divider() + Divider() + } Button(action: { presentLogOutConfirm() }, label: { HStack { diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 6e37282c3..dc197ddc4 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -81,6 +81,7 @@ public final class SettingsViewModel { /// The signed-in instance, shown as the "Current Learning Site" section in Settings. var currentInstance: Instance? { instanceStore.currentInstance } + var hasMultipleInstances: Bool { instanceStore.instancesConfig.instances.count > 1 } public init( interactor: ProfileInteractorProtocol, diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index bb1c490c3..d7bb405a7 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -24,9 +24,11 @@ public struct VideoQualityView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) + .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("auth_bg_image") diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index f390fc926..80ddb987f 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -22,9 +22,11 @@ public struct VideoSettingsView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) + InstanceThemedImage( + source: viewModel.currentInstance?.headerBackgroundURLString, + fallback: ThemeAssets.headerBackground.swiftUIImage + ) + .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("auth_bg_image") diff --git a/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift b/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift index cfd08acf4..0d62f0004 100644 --- a/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift +++ b/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift @@ -48,7 +48,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) // When @@ -82,7 +83,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) // When @@ -120,7 +122,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) // When @@ -170,7 +173,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) // When @@ -201,7 +205,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) // When @@ -229,7 +234,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) // When @@ -263,7 +269,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) viewModel.coursesForSync = [course] @@ -321,7 +328,8 @@ final class DatesAndCalendarViewModelTests: XCTestCase { profileStorage: profileStorage, persistence: persistence, calendarManager: calendarManager, - connectivity: connectivity + connectivity: connectivity, + instanceStore: InstanceStore() ) viewModel.calendarName = "New Calendar"